Design Lounge
Install

Nº 659 of 677

Cyber VPN shield

A neon-on-black VPN screen: a 48-tick dial charges and locks with a scanline sweep, a dot-matrix world map draws the route, and live counters tick.

Preview

Open full size
390 × 844 · 16.2 KB · 217 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Cyber VPN shield" (piece style-cyber-vpn-shield) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

205 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The main screen of a fictional privacy app, Hexgate. A dot-matrix world map shows the tunnel from Lisbon to the chosen exit, three chamfered counters tick live, four exit servers carry ping bars, and a 200px dial in the thumb zone is the one control. Tapping the dial when exposed charges its 48 ticks clockwise while a scanline sweeps the core, then the shackle drops, the core glows cyan, the route draws itself across the map and the public IP scrambles into the new one. The style is cyber on a custom language (not iOS or Material): neon cyan on near-black, chamfered 12px corners cut with clip-path, faint 3px scanlines over everything, one hot magenta used only for “you are exposed”. The detail worth copying is that the neon is only on state: body text is a cool grey that passes 7:1, so the glow means something.

Reference behaviour

  1. Initial state: shield locked through Montréal. Chip “SHIELDED” in cyan. The map shows a magenta 6px square at Lisbon, a cyan arc to Montréal with a cyan exit dot, a pinging ring and a light packet dot travelling the arc every 2.2s. Legend: “LIS ▸ MTL · 5,231 km” and “WIREGUARD · AES-256”. Counters: DOWN 1.284 GB, UP 96.4 MB, SESSION 00:42:17. Public IP “149.56.19.145 · Montréal” in cyan. All 48 ticks lit; the dial reads LOCKED / TAP TO DROP. Note: “Kill switch on · DNS in tunnel · no logs”.
  2. Counters advance every 1s while locked: down by 0.2–1.8 MB, up by 20–140 KB, session by one second. Values switch from MB to GB at 1,000 MB with three decimals.
  3. Tap the dial (drop): chip turns magenta “EXPOSED”, route, ring and packet hide, ticks go dim, the core border turns grey, the shackle lifts 5px, the word reads EXPOSED / TAP TO SHIELD. IP becomes “94.63.112.8 · Lisbon, visible” in magenta. Note: “Traffic is not encrypted on this network”. Counters freeze.
  4. Tap again (charge): chip “LINKING” in cyan. Ticks light one by one clockwise from 12 o’clock, 30ms apart (1.44s). A 2px cyan scanline with a 10px glow sweeps the core top to bottom every 520ms. The label counts “HANDSHAKE 000%” → “100%” over 1500ms. Note: “Handshake with Montréal · key rotated”. The dial ignores taps while charging.
  5. At 1560ms: LOCKED. One slower sweep (900ms), the shackle drops in 260ms with an overshoot, the core gains a cyan border and glow, the route draws from Lisbon in 700ms (expo out), the IP digits scramble for 450ms and land on the exit IP, and counters restart from 0.0 MB and 00:00:00.
  6. Pick a server (tap a row or use arrow keys in the list): the row gets a cyan border, a 3px inset cyan bar on the left and a cyan code. While locked, the route redraws to the new exit and the IP scrambles to its address; while exposed, only the map and legend update.
  7. Servers: IS·RKV Reykjavík “Geothermal · 31% load” 38 ms; CH·ZRH Zürich “Double hop · 54% load” 24 ms; CA·MTL Montréal “Streaming · 48% load” 96 ms; JP·TYO Tokyo “P2P allowed · 62% load” 212 ms. Bars: under 40ms 4, under 100ms 3, under 180ms 2, else 1.

Structure

390 × 844 (54 safe top, 34 safe bottom, 20px gutters)
┌──────────────────────────────────────┐
│ ⬡ HEXGATE                [■ SHIELDED]│ header 44; chip 28 tall, chamfer 8
│ ╱──────────────────────────────────┐ │ map card, chamfer 12, padding 8/10
│ │ ⠿⠿⠿⠿⠿    ⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿      │ │ SVG viewBox 360×140, 5px dot pitch
│ │  ⠿●╭────╮■⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿          │ │ route: quadratic arc
│ │     ⠿⠿   ⠿⠿⠿⠿    ⠿⠿⠿             │ │
│ │ LIS ▸ MTL · 5,231 km   WIREGUARD… │ │ legend 11px
│ └──────────────────────────────────╱ │
│ ╱DOWN    ┐ ╱UP      ┐ ╱SESSION ┐    │ 3 stat cards, gap 8
│ │1.284 GB│ │96.4 MB │ │00:42:17│    │
│ PUBLIC IP        149.56.19.145 · Mon…│ 12px row
│ ┌──────────────────────────────────┐ │ 4 server rows, 42 tall, gap 6
│ │IS·RKV Reykjavík        ▂▄▆█ 38 ms│ │ grid 44 / 1fr / 40 / 52
│ ┃CA·MTL Montréal         ▂▄▆░ 96 ms┃ │ selected: cyan border + 3px bar
│ └──────────────────────────────────┘ │
│            ╭ ' ' ' ' ' ╮             │ dial 200×200, 48 ticks r87–95
│           '  ( lock )   '            │ core inset 26 (148px)
│           '   LOCKED    '            │
│            ╰ TAP TO DROP╯            │
│   Kill switch on · DNS in tunnel …   │ note 12px
└──────────────────────────────────────┘
  • <main class="app"> flex column, padding:54px 20px 34px; state classes .locked, .off, .linking on it.
  • <header>: logo SVG, <h1>hexgate</h1>, <span class="chip" role="status">.
  • <section class="card map" aria-label="Route map">: <svg aria-hidden="true"> with a dots group, path.route, nodes group; a .legend row in HTML.
  • <section class="stats">: three .card.stat with a label span and a <b> value.
  • <p class="ip">.
  • <ul role="radiogroup" aria-label="Exit server"> with four <button class="srv" role="radio">.
  • <section class="zone">: <button class="dial" aria-pressed> holding the ticks SVG and a .core (scanline, lock SVG, word, percent); then the .note.
  • A visually hidden aria-live="polite" paragraph.

Tokens

:root {
  /* surfaces */
  --bg: #06080C;        /* near-black; overlaid with 1px cyan lines at 3.5% every 3px */
  --panel: #0B1017;     /* cards, rows */
  --line: #1B2632;      /* card border */
  --edge: #5A6E83;      /* server row border, core border when exposed (3.6:1 on panel) */
  --dim: #1C2834;       /* unlit ticks, unlit ping bars */
  --dot: #24384A;       /* map land dots */
  --selected: #0E1D21;  /* selected server fill */
  /* text */
  --ink: #D6E2EC;       /* values, names (14.8:1 on --bg) */
  --ink-2: #93A7B8;     /* labels, notes (7.9:1 on --bg) */
  /* state */
  --cyan: #2FF3D6;      /* shielded: chip, route, ticks, selected, IP */
  --mag: #FF5FA2;       /* exposed only: chip, home node, IP (6.4:1 on --bg) */
  --glow: rgba(47, 243, 214, .25);
  /* type */
  --display: "Major Mono Display", ui-monospace, monospace;
  --mono: "IBM Plex Mono", ui-monospace, monospace;
  --fs-brand: 20px; --fs-word: 20px; --fs-stat: 16px; --fs-city: 14px; --fs-body: 13px;
  --fs-ip: 12px; --fs-label: 11px; --fs-micro: 10px;
  /* shape */
  --chamfer-card: 12px; --chamfer-chip: 8px; --row-h: 42px; --dial: 200px; --core-inset: 26px;
  --tick-count: 48; --tick-r1: 87px; --tick-r2: 95px; --tick-major: 99px;
  /* shadows */
  --core-lock: 0 0 0 4px rgba(47,243,214,.08), 0 0 28px rgba(47,243,214,.25);
  --scan-glow: 0 0 10px 2px rgba(47,243,214,.6);
  --route-glow: drop-shadow(0 0 3px rgba(47,243,214,.7));
  /* motion */
  --std: cubic-bezier(0.2, 0.7, 0.2, 1);
  --expo: cubic-bezier(0.16, 1, 0.3, 1);
  --snap: cubic-bezier(0.5, 1.8, 0.5, 1);
  --t-charge: 1500ms; --tick-stagger: 30ms; --t-scan: 520ms; --t-scan-lock: 900ms;
  --t-route: 700ms; --t-shackle: 260ms; --t-ping: 2400ms; --t-packet: 2200ms; --t-scramble: 450ms;
}

Typography

RoleFamilySize / lineWeightTrackingCase
BrandMajor Mono Display20px / 14000.02emas the font draws it
Dial wordMajor Mono Display20px / 14000.04em—
Stat valueIBM Plex Mono16px / 1, tabular5000—
CityIBM Plex Mono14px / 15000title
PingIBM Plex Mono13px / 1, tabular5000—
Body, noteIBM Plex Mono12–13px / 1.44000.04emsentence
ChipIBM Plex Mono11px / 16000.14emupper
Legend, server code, percentIBM Plex Mono11px / 1500–6000.08–0.16emupper
Stat label, server noteIBM Plex Mono10px / 14000.06–0.16emupper / sentence

Motion

WhatTriggerPropertyFrom → toDurationEasingDelayReduced motion
Tick chargeconnecttick stroke--dim → cyan60ms each--stdi × 30msall at once
Scanlinechargingtop, glow−4% → 104%520ms looplinear0none
Lock sweeplockedtop−4% → 104%900ms once--std0none
Percentchargingtext000% → 100%1500msrAF linear0300ms
Shacklelocked / droptranslateY−5px ↔ 0260ms--snap0same
Core glowlockedborder, shadowgrey → cyan200 / 300ms--std0same
Route drawlocked, reroutestroke-dashoffsetlength → 0700ms--expo0drawn
Exit pinglockedring scale, opacity0.4, 1 → 2.4, 02400ms loop--std0none
PacketlockedSMIL animateMotionalong route2200ms looplinear0not created
IP scramblelocked, reroutedigitsrandom → final9 × 50ms—0final at once
Row selecttapborder, fillgrey → cyan160ms--std0same

States

  • Locked: cyan chip, cyan ticks, cyan core border with glow, closed shackle, LOCKED, route visible, counters running.
  • Linking: cyan LINKING chip, ticks filling, scanline looping, percent counting; route still hidden; the IP stays magenta until the lock lands.
  • Exposed: magenta chip and IP, dim ticks, grey core, open shackle, EXPOSED / TAP TO SHIELD, route hidden, counters frozen.
  • Server row: default grey 1px border; hover #8CA0B3; selected cyan border, #0E1D21 fill, 3px inset cyan bar, cyan code.
  • Focus-visible: 2px cyan outline, 3px offset, on the dial (round) and on rows.
  • Busy: taps on the dial do nothing while linking.

Accessibility

  • The dial is a <button aria-pressed> whose aria-label reads “Shield on. Tap to turn off”, “Connecting” or “Shield off. Tap to connect”.
  • The chip is role="status"; a hidden aria-live="polite" line announces “Shield locked. Exit Montréal, 96 milliseconds.”, “Shield off. Your IP is visible.”, “Rerouted through Tokyo.”
  • Servers are a role="radiogroup" of role="radio" buttons with aria-checked and a roving tabindex; Arrow keys move and select, Tab leaves the group. Each has aria-label="Montréal, 96 milliseconds" so the bars are not the only signal.
  • The map SVG is aria-hidden; the legend repeats the route in text.
  • Contrast: --ink 14.8:1 and --ink-2 7.9:1 on --bg; cyan 13.9:1, magenta 6.4:1. Row borders are 3.6:1 against the panel so the radios meet 3:1.
  • Hit targets: dial 200px, rows 350 × 42.

Responsive rules

  • At 360 wide: stats keep three columns (values 16px fit “1.284 GB” in 98px); the server note may truncate; the map scales with the SVG viewBox.
  • Short screens (under 800px tall): the dial shrinks to 168px and its core inset to 22px; the zone keeps margin-top:auto.
  • Tablet: cap the column at 430px; the map card can grow to 600px wide beside the dial in landscape.

Acceptance checklist

Always

  • One control changes the shield: the dial, in the lower third.
  • Charging lights 48 ticks clockwise from 12 o’clock with a 30ms stagger while a scanline sweeps the core.
  • Locking drops the shackle, glows the core, draws the route and scrambles the IP, in that order within 700ms.
  • Neon is only on state; body and label text use --ink / --ink-2 and pass 4.5:1.
  • Magenta appears only while exposed (and on the home node).
  • Hidden route, ring and packet are visibility:hidden, not just transparent.
  • Server rows are radios with arrow-key movement and a 3:1 border.
  • Counters only tick while locked and reset on a new connection.
  • Reduced motion: no scan, no ping, no packet; ticks and route appear at once.
  • document.documentElement.scrollWidth is 390.

This demo

  • Brand “hexgate”; home Lisbon 94.63.112.8; default exit Montréal 149.56.19.145.
  • Four servers with pings 38, 24, 96 and 212 ms and the bar thresholds 40 / 100 / 180.
  • Starting counters 1.284 GB, 96.4 MB, 00:42:17.

Implementation notes

  1. A world map from row ranges. Encode land at 10° per cell as [start, end] column ranges per row (14 rows from 80°N), then upsample each cell to 2 × 2 dots at a 5px pitch and drop the outer corner dot when both neighbours on that side are sea. Project nodes with x = lon + 180, y = 80 − lat in a 0 0 360 140 viewBox.
G.forEach((g, y) => g.forEach((v, x) => { if (!v) return;
  for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) {
    if (!on(y + (dy ? 1 : -1), x) && !on(y, x + (dx ? 1 : -1))) continue;
    d += `<circle class="dot" cx="${x*10 + dx*5 + 2.5}" cy="${y*10 + dy*5 + 2.5}" r="1.25"/>`;
  } }));
  1. Ticks that charge with CSS alone. Give each tick its index and let the transition delay do the sequence; a .now class skips the delay for the first frame.
.tick { stroke: var(--dim); stroke-width: 2.4; transition: stroke 0s; }
.lit .tick { stroke: var(--cyan); transition: stroke 60ms var(--std) calc(var(--i) * 30ms); }
.lit.now .tick { transition: none; }
.charging .scan { animation: sweep 520ms linear infinite; }
@keyframes sweep { 0% { top: -4%; opacity: 1 } 100% { top: 104%; opacity: 1 } }
  1. Redraw the route from its real length. Measure after setting d, store it as --len, and restart the animation by toggling the class after a reflow.
route.setAttribute('d', `M${x0} ${y0}Q${mx} ${my} ${x1} ${y1}`);
route.style.setProperty('--len', Math.ceil(route.getTotalLength()));
route.classList.remove('draw'); void route.getBBox(); route.classList.add('draw');

Common mistakes: making every label neon (it stops meaning anything and the small text blurs), a glow behind body text, and letting a second tap during the handshake flip the state mid-charge. Clear every pending timeout on drop.

Details

Palette
Type
Major Mono Display · IBM Plex Mono
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

Put each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.

All sources

Install once. Ask for anything.

The MCP server gives your agent 600+ pieces, the themes and the review tools. It installs the skill too. Free, no account.

claude mcp add -s user design-lounge -- npx -y design-lounge-mcp@latest

Claude Code. For Cursor, Codex, VS Code, ChatGPT and the skill alone, see Install.