Design Lounge
Install

Nº 085 of 520

Magnetic buttons

Three CTAs and a nav row whose outlines and labels are pulled toward the cursor inside an 80px radius (up to 10px) and spring back over 500ms; keyboard focus works without the effect.

Preview

Open full size
1280 × 800 · 8.9 KB · 129 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Magnetic buttons" (piece magnetic-buttons) 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

210 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

A launch-control hero for a fictional mission-ops product (“Orbital”) whose interactive controls are magnetic: when the pointer comes within 80px of a button or nav link, its outline (background/border layer) slides toward the cursor by up to 10px and its label slides 1.5× further, so the label appears to lead. When the pointer leaves the radius both layers spring back over 500ms with a small overshoot. The effect is pointer-only and cosmetic: keyboard focus shows a two-ring lime focus ring on the outline layer with no translation, and pointer: coarse or prefers-reduced-motion disables it entirely. Dark near-black surfaces, a faint 64px grid, one lime accent. The detail worth copying is the two-layer structure (.outline + .label) driven by two custom properties, which keeps the JS to a distance check.

Reference behaviour

  1. Initial state: 64px top bar with brand mark + “Orbital”, five pill nav links (“Overview” is current, with a visible 1px border), and a right-aligned mono status (“Pad 3 · nominal”, “T−00:14:32”). The main area is vertically centred: a lime mono kicker, a 64px two-tone headline, a mono sub-paragraph and three buttons; a four-column telemetry strip sits above the bottom edge.
  2. Move the pointer toward any .mag element. When the distance from the pointer to the nearest edge of the element’s box is < 80px, the element gets class near: its .outline layer translates toward the pointer (max 10px on each axis, proportional to the pointer’s offset from the element centre) and its .label translates 1.5× that. While near, transforms track the pointer with a 120ms standard-ease transition (throttled to one requestAnimationFrame per pointermove).
  3. Hovering also changes surfaces: nav pills gain a --panel fill and --line border and their text brightens to --ink; the primary button lightens to #d6f866; the secondary border brightens to --ink-2; the ghost button shows a dashed --line-2 border and its text brightens.
  4. Move the pointer outside the 80px radius: near is removed, both custom properties are reset to 0px, and the layers return over 500ms with cubic-bezier(.34,1.56,.64,1) (visible overshoot of ~2px).
  5. When the pointer leaves the document, every near element is released the same way.
  6. Tab through the page: each .mag shows a focus ring (0 0 0 2px var(--bg), 0 0 0 4px var(--accent)) on its .outline; nothing translates.
  7. Clicking a nav link moves aria-current="page" to it (the border follows). Buttons are inert beyond hover/press styling.
  8. With a coarse pointer or prefers-reduced-motion: reduce, the script exits early; CSS forces transform: none on both layers, so the page is fully usable with hover colours only.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ top 64  ◎ Orbital  (Overview)(Missions)(Telemetry)(Crew)(Settings)   │
│                                     · Pad 3 · nominal   T−00:14:32   │
├──────────────────────────────────────────────────────────────────────┤
│                                  hint: Move the cursor near a control│
│  MISSION KESTREL-7 · WINDOW 2 OF 3                                   │
│  Launch window opens in                                              │
│  fourteen minutes.                          (h1 64px, max 820px)     │
│  sub-paragraph mono, max 560px                                       │
│  ┌ Start countdown → ┐ ┌ Hold sequence ┐  View telemetry ↗           │
│  └───────────────────┘ └───────────────┘   (56px tall, 16px gap)     │
│                                                                      │
│  ── telemetry strip: 4 cols, mono, 1px top rule ── bottom 32px ───   │
└──────────────────────────────────────────────────────────────────────┘
   each .mag:   ┌─ .outline (absolute, inset 0, moves --ox/--oy) ─┐
                │      .label (relative, moves 1.5 × --ox/--oy)   │
                └─────────────────────────────────────────────────┘
  • <header class="top"> — .brand (22px SVG orbit glyph + name), <nav class="nav" aria-label="Primary"> of <a class="mag">, .status with margin-left: auto.
  • <main> — flex column, justify-content: center, padding 0 40px 40px; contains .hint (absolute, top-right), p.kicker, h1 (second clause in a <span> at --ink-3), p.sub, .actions (flex, 16px gap) and .tele (absolute, 32px from the bottom).
  • Every magnetic control is <button class="mag btn …"> or <a class="mag"> containing exactly <span class="outline"></span><span class="label">…</span>. The control itself has no background or border — the .outline span carries them and inherits border-radius.
  • A fixed body::before draws the 64px grid with two linear-gradients at 28 % opacity.

Tokens

:root {
  /* colour — cool near-black, one lime accent */
  --bg: #0b0d10;          /* page */
  --panel: #12151a;       /* secondary button, nav hover fill */
  --line: #232830;        /* grid, hairlines, nav hover border */
  --line-2: #3a4250;      /* secondary border, current nav border */
  --ink: #e8ebf0;         /* primary text */
  --ink-2: #9aa3b2;       /* nav, sub copy, ghost label */
  --ink-3: #5f6875;       /* h1 second clause, hint, telemetry labels */
  --accent: #c8f04a;      /* primary fill, kicker, status dot, focus ring */
  --accent-hover: #d6f866;
  --accent-ink: #131a02;  /* text on accent */

  /* type */
  --sans: "Space Grotesk", system-ui, sans-serif;
  --mono: "IBM Plex Mono", ui-monospace, monospace;

  /* layout */
  --top-h: 64px;
  --pad-x: 40px;
  --btn-h: 56px;  --btn-px: 26px;
  --nav-h: 36px;  --nav-px: 14px;
  --grid: 64px;
  --r-btn: 12px;
  --r-pill: 999px;

  /* magnet */
  --radius: 80px;        /* activation distance from the box edge */
  --pull: 10px;          /* max outline translation per axis */
  --pull-label: 1.5;     /* label multiplier → max 15px */

  /* motion */
  --t-track: 120ms;      /* while near: follows pointer */
  --t-return: 500ms;     /* spring back */
  --t-micro: 160ms;      /* colour changes */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --spring: cubic-bezier(.34, 1.56, .64, 1);
  --ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BodySpace Grotesk15px5001.50sentence
BrandSpace Grotesk17px7001−0.01emsentence
Nav linkSpace Grotesk14px50010sentence
StatusIBM Plex Mono13px400 (value 500)10numerals
KickerIBM Plex Mono12px4001+0.14emUPPERCASE
HeadlineSpace Grotesk64px7000.98−0.035emsentence
Sub copyIBM Plex Mono14px4001.70sentence
Button labelSpace Grotesk16px7001−0.01emsentence
Telemetry labelIBM Plex Mono12px4001.40sentence
Telemetry valueIBM Plex Mono15px5001.40numerals
HintIBM Plex Mono12px40010sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasing
.mag .outlinepointer enters 80pxtransformtranslate(0,0) → translate(—ox,—oy), each ≤ 10px120ms (tracking)--ease
.mag .labelpointer enters 80pxtransform0 → 1.5 × (—ox,—oy), each ≤ 15px120ms--ease
.mag .outline/.labelpointer leaves radiustransformcurrent → translate(0,0)500ms--spring (overshoot)
nav .outlinehover / nearbackground, border-colortransparent → --panel, --line160ms--ease
primary .outlinehover / nearbackground--accent → --accent-hover160ms--ease
secondary .outlinehover / nearborder-color--line-2 → --ink-2160ms--ease
ghost .outlinehover / nearborder-colortransparent → --line-2 (dashed)160ms--ease
labelshover / nearcolor--ink-2 → --ink160ms--ease

The pull is proportional, not binary: ox = (pointerX − centreX) / (width/2 + 80) × 10, so a pointer sitting exactly at the radius edge produces the full 10px and one at the centre produces 0. Reduced motion: the script returns early and CSS sets transform: none !important with 1ms transitions on both layers; hover colours remain.

States

  • Rest: control has no visible box of its own; .outline carries fill/border. Primary: --accent fill, --accent-ink label. Secondary: --panel fill, 1px --line-2 border. Ghost: transparent, 1px dashed transparent border, label --ink-2. Nav: transparent, label --ink-2.
  • Near / hover: see Motion table; class near and :hover share the same colour rules so the pointer entering the radius reads as hover before the pointer reaches the box.
  • Focus-visible: outline: 0 on the control; .outline gets box-shadow: var(--ring). No translation.
  • Current nav: aria-current="page", label --ink, .outline border --line-2.
  • Active (press): none beyond hover; add transform: scale(.98) on .label in your system if you need press feedback.
  • Coarse pointer / reduced motion: JS never attaches; only colour states apply.

Accessibility

  • Nav links are <a> inside <nav aria-label="Primary">; actions are <button type="button">. The decorative .outline span is empty and has no role.
  • Keyboard: Tab order is brand → five nav links → three buttons. Enter/Space activate as native. The magnet never moves a focused control, so focus rings stay aligned with the hit area.
  • The activation logic runs only when matchMedia('(pointer: fine)') matches, so touch devices get a plain UI.
  • Contrast: --ink-2 on --bg 7.4:1; --ink-3 (5.6px+ mono meta, h1 clause) 4.6:1; --accent-ink on --accent 13:1.
  • Hit targets: buttons 56px tall; nav pills 36px tall with 14px side padding. The translated outline never moves more than 10px, so the hit target (the untransformed control) stays under the visual.
  • Status dot is decorative; the text “Pad 3 · nominal” carries the meaning.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: headline 56px; telemetry strip stays 4 columns.
  • 768–1023: headline 48px; nav hides “Crew” and “Settings” behind a “More” pill; telemetry becomes 2 × 2.
  • < 640: headline 36px; actions stack vertically at full width (56px tall each); magnet disabled (coarse pointer); telemetry strip becomes a single column and is no longer absolutely positioned.

Acceptance checklist

  • Every magnetic control is .mag > .outline + .label, and the control itself has no background or border.
  • A pointer within 80px of a control’s box edge (not its centre) sets class near and translates .outline by ≤ 10px per axis toward the pointer.
  • .label translates 1.5× the outline (≤ 15px) in the same direction.
  • Pull magnitude is proportional to pointer offset from the control centre, divided by (half-size + 80).
  • While near, transforms follow the pointer with a 120ms transition, updated at most once per animation frame.
  • On leaving the radius, both layers return over 500ms with cubic-bezier(.34,1.56,.64,1) and a visible overshoot.
  • Pointer leaving the document releases every near control.
  • Focus-visible shows the two-ring lime box-shadow on .outline with zero translation.
  • With (pointer: coarse) or prefers-reduced-motion: reduce, no listeners attach and both layers have transform: none.
  • Nav click moves aria-current="page"; the current pill shows a --line-2 border.
  • Primary/secondary/ghost hover colours match the tokens and use 160ms transitions.
  • No console errors when the pointer moves rapidly across all eight controls.

Implementation notes

Distance to the box, not the centre. Clamp the pointer to the rect to find the nearest point, then measure. This makes wide buttons and small pills feel equally responsive:

const R = 80, MAX = 10;
for (const el of mags) {
  const r = el.getBoundingClientRect();
  const nx = Math.max(r.left, Math.min(px, r.right)), ny = Math.max(r.top, Math.min(py, r.bottom));
  const d = Math.hypot(px - nx, py - ny);
  if (d < R) {
    const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
    el.style.setProperty('--ox', ((px - cx) / (r.width / 2 + R) * MAX).toFixed(2) + 'px');
    el.style.setProperty('--oy', ((py - cy) / (r.height / 2 + R) * MAX).toFixed(2) + 'px');
    el.classList.add('near');
  } else if (el.classList.contains('near')) {
    el.classList.remove('near'); el.style.setProperty('--ox', '0px'); el.style.setProperty('--oy', '0px');
  }
}

Two clocks on one transition. The return spring is the default; the near class shortens it for tracking so the layers never lag the pointer:

.mag { --ox: 0px; --oy: 0px; position: relative; }
.mag .outline { position: absolute; inset: 0; border-radius: inherit;
  transform: translate(var(--ox), var(--oy)); transition: transform 500ms cubic-bezier(.34,1.56,.64,1); }
.mag .label { position: relative; transform: translate(calc(var(--ox) * 1.5), calc(var(--oy) * 1.5));
  transition: transform 500ms cubic-bezier(.34,1.56,.64,1); }
.mag.near .outline, .mag.near .label { transition-duration: 120ms; transition-timing-function: cubic-bezier(.2,.7,.2,1); }

Throttle with rAF and read rects inside it:

let px = -1e4, py = -1e4, raf = 0;
document.addEventListener('pointermove', e => { px = e.clientX; py = e.clientY; if (!raf) raf = requestAnimationFrame(update); });
document.addEventListener('pointerleave', () => { px = py = -1e4; if (!raf) raf = requestAnimationFrame(update); });

Common mistakes: putting the transform on the control itself (the hit area moves out from under the cursor and focus rings drift); using mouseenter on the element instead of a document-level distance check (the pull must start before hover); forgetting to reset --ox/--oy when removing near; measuring rects on every pointermove without rAF throttling.

Details

Palette
Type
Space Grotesk · IBM Plex Mono
Motion
Rich motion
Build
Quick build
Designed bySusan AcharyaKathmandu

Studied

Noomo

One scroll, one story. Motion carries the page.

All sources