Design Lounge
Install

Nº 573 of 605

Spotlight onboarding tour

A four-stop coach-mark tour over an invoicing app: a spotlight cutout that glides between targets, a dark popover with step count, Back, Next and Skip.

Preview

Open full size
1280 × 800 · 18.2 KB · 218 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 "Spotlight onboarding tour" (piece popup-onboarding-tour) 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

212 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 first-run tour of Pennant, an invoicing app for small studios. The whole screen dims except a rounded cutout around one real control, ringed in saffron, and a near-black popover beside it says what that control does for this studio, in this studio’s numbers. Four stops: New invoice, the Overdue filter, a “Viewed twice” status, the Next payout card. Between stops the cutout does not blink off and on: it glides, changing position, size and corner radius over 460ms, and the popover glides with it while its text cross-fades. The detail worth copying is the cutout itself: one element with a 9999px box-shadow, so the dim, the hole and the ring are a single box that can animate.

Reference behaviour

  1. Initial state: the tour is already running at stop 1. The cutout hugs the “New invoice” button (6px padding, radius = button radius + 6px) with a 2px saffron ring and a soft halo pulse. The popover sits below it, its arrow pointing at the button’s centre, reading “Stop 1 of 4”, “Start an invoice here”, “Pick a client and Pennant fills in their day rate, payment terms and VAT number.” Back is hidden.
  2. Next (or ArrowRight) moves to the next stop. The popover body fades to 0 over 140ms, the text is replaced, then the cutout and popover move together over 460ms on cubic-bezier(.16,1,.3,1) and the body fades back in over 200ms. The four 18 × 3px progress pips fill saffron up to the current stop.
  3. Stops and preferred placement:
    • 1 New invoice, below. 2 the Overdue tab, below (“Late invoices stand out”). 3 the “Viewed twice” status chip on INV-0148, left (“See what the client did”). 4 the Next payout card, left (“Know when money lands”).
    • If the preferred side lacks room (16px margin), left falls back to below, and below falls back to above. The popover is then clamped inside the viewport and the arrow is clamped 14px from the popover’s corners, so it always points at the target.
  4. Back (or ArrowLeft) goes to the previous stop. On stop 4, Next reads “Finish”.
  5. Finish, Skip tour and Escape end the tour. A dark toast confirms: “Tour finished. It is under Take the tour if you want it again.” or “Tour skipped. Take the tour is in the sidebar.” for 3000ms. Focus goes to the “Take the tour” button in the sidebar’s help card, which restarts at stop 1.
  6. Before measuring, the target is scrolled into view. On resize the current stop is placed again.
  7. The halo is the one loop: a ring that grows from 0 to 12px and fades over 2.4s, behind the saffron line.

Structure

1280 × 800
┌───────────┬────────────────────────────────────────────────────────────┐
│ ▶ Pennant │ Invoices (40px condensed)                  ╔[+ New invoice]╗│
│ Invoices  │ Studio Ferro, 48 invoices...               ╚═══════════════╝│
│ Clients   │ (All 48) Draft 3 Sent 9 Overdue 2 Paid 34   ┌──── tip 320 ──▲┐│
│ Payouts   │ ┌ table ──────────────────────────────┐ ┌──│ ━━ ── ── ──  │ │
│ Expenses  │ │ Number Client      Due  Status  Amt │ │  │ Stop 1 of 4  │ │
│ Settings  │ │ INV-0148 Holm & D. 14 Nov Viewed ...│ │  │ Start an ... │ │
│           │ │ ...                                  │ │  │ [Skip]  [Next]││
│           │ └──────────────────────────────────────┘ │  └──────────────┘ │
│ ┌ help ─┐ │                                          │ payout card 300  │
│ │ Take  │ │                                          └──────────────────│
│ │ tour  │ │   everything outside ╔══╗ is under rgba(26,26,23,.56)       │
└───────────┴────────────────────────────────────────────────────────────┘
  • App: <nav class="side"> (232px) with brand, links, and a .help card holding “Take the tour”. <main class="main" id="main"> scrolls; it holds the header (h1, meta, New invoice), the tabs, the invoice table (CSS grid rows, 52px) and the payout <aside class="card">.
  • Tour: <dialog id="tour" aria-labelledby="tt" aria-describedby="tp"> opened with showModal(), styled full-viewport and transparent (inset:0; width:100%; height:100%; max-width:none; background:transparent), with a transparent ::backdrop.
    • .spot: absolutely positioned at 0,0, moved with transform: translate(), sized with width/height, box-shadow: 0 0 0 2px var(--ring), 0 0 0 9999px var(--scrim), pointer-events: none. ::after carries the halo.
    • .tip (tabindex="-1", 320px): .arr (14px rotated square), .meta (pips + #tc step count, aria-live="polite"), .body (<h2 id="tt">, <p id="tp">), .ft (Skip tour, Back, Next).
  • .toast role="status" outside the dialog.

Tokens

:root {
  /* app, warm paper grey */
  --bg: #f6f5f1;
  --surface: #ffffff;
  --sunk: #efede7;
  --line: #e4e1d8;
  --line-2: #d2cec2;
  --edge: #858173;        /* outline buttons, 3.90:1 on white */
  --ink: #1a1a17;         /* 17.44:1 */
  --ink-2: #4b4a43;       /* 8.90:1 */
  --ink-3: #6a685e;       /* 5.59:1 on white, 5.13:1 on bg */
  /* status chips (live state only) */
  --ok: #2f6b3f;   --ok-soft: #e4efe3;    /* 5.39:1 */
  --warn: #8a4a12; --warn-soft: #f8e9d6;  /* 5.74:1 */
  --info: #3b4470; --info-soft: #e8eaf3;  /* 7.79:1 */
  /* tour */
  --tip: #1d1c19;         /* popover */
  --tip-2: #2b2a26;       /* popover button hover */
  --tip-ink: #f6f4ee;     /* 15.49:1 */
  --tip-ink-2: #c9c6bb;   /* 9.96:1 */
  --accent: #f0b429;      /* saffron: ring, pips, primary */
  --accent-ink: #1d1c19;  /* 9.14:1 on accent */
  --ring: rgba(240,180,41,.9);
  --scrim: rgba(26,26,23,.56);
  /* type */
  --font: "Hubot Sans", system-ui, sans-serif;     /* wdth 75..125, wght 400..800 */
  --num: "Space Grotesk", ui-sans-serif, sans-serif;
  /* size */
  --pad: 6px;             /* cutout padding around the target */
  --gap: 14px;            /* target to popover */
  --margin: 16px;         /* popover to viewport edge */
  --tip-w: 320px;
  --r: 12px;
  --r-ctl: 10px;
  --h-ctl: 38px;
  /* motion */
  --t-micro: 160ms;
  --t-in: 320ms;
  --t-move: 460ms;
  --t-swap: 140ms;        /* text out, before the move */
  --ease: cubic-bezier(.2,.7,.2,1);
  --ease-out: cubic-bezier(.16,1,.3,1);
}

Typography

Hubot Sans carries the words at two widths; Space Grotesk carries every figure, because Hubot’s barred zero reads as a strikethrough in an amount column at 13px.

RoleFamilySizeWeightWidthLine-heightTracking
Page h1Hubot Sans40px80078%1-0.01em
Popover titleHubot Sans19px70085%1.2-0.005em
Popover bodyHubot Sans14px400100%1.50
Step countSpace Grotesk13px500n/a1.40.01em
Nav, buttonsHubot Sans13.5–14px500–600100%10
Table bodyHubot Sans13.5px400100%1.350
Amounts, invoice ids, datesSpace Grotesk13.5px600 / 400n/a1tabular
Payout figureSpace Grotesk34px600n/a1-0.03em, tabular
Status chipHubot Sans12px600100%24px box0

Motion

ElementTriggerPropertyFrom → ToDurationEasing
tour layeropenopacity0 → 1320ms--ease
.spotstop changetransform, width, height, border-radiusprevious box → target box + 6px460ms--ease-out
.tipstop changetransformprevious → new placement460ms--ease-out
.arrstop changeleft, topprevious → new460ms--ease-out
.tip .bodystop changeopacity1 → 0 (140ms, then text swaps) → 1200ms--ease
pipsstop changebackgroundgrey → saffron160ms--ease
halo ::afterloopbox-shadow spread, alpha0 at .55 → 12px at 02400ms--ease
toasttour endsopacity, translateY0, 12px → 1, 0320ms--ease-out

The first placement (on open, and again when web fonts finish loading) runs with transitions off (a .still class for two frames), so the cutout never flies in from the corner. Reduced motion: all durations 1ms, no halo.

States

  • Popover buttons: primary saffron fill, hover #f5c454; Back outlined #6e6b61 (3.20:1 on the popover), hover --tip-2; Skip tour ghost in --tip-ink-2.
  • Back: hidden on stop 1. Next: “Finish” on stop 4.
  • Pips: filled saffron up to and including the current stop.
  • Target: untouched; the ring belongs to the cutout, not the target, so no target styles change.
  • Focus-visible: 2px saffron outline, 2px offset, inside the popover; 2px ink outline in the app.
  • Ended: toast for 3000ms; “Take the tour” restarts.

Accessibility

  • showModal() makes the app inert and traps Tab in the popover: Skip tour, Back, Next. Escape ends the tour (native cancel).
  • On open the popover itself takes focus (tabindex="-1", no outline), and the dialog is named by the stop title and described by its body, so each stop is read in full.
  • #tc is aria-live="polite", so “Stop 2 of 4” is announced on each move. Pips are aria-hidden.
  • ArrowRight and ArrowLeft move between stops while focus is in the tour.
  • The cutout is decoration (pointer-events: none); targets are not clickable during the tour. Say so in the copy if your product needs the user to act at a stop.
  • Contrast: all text passes 4.5:1 (figures in Tokens). The dim is 56% so the app stays legible as context.
  • On end, focus goes to the control that restarts the tour.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: the payout card moves under the table (single column); stops 3 and 4 fall back to below or above automatically.
  • < 820: sidebar hidden; a “Take the tour” outline button appears beside New invoice. .main gets 300px of bottom padding so the last target can scroll above the popover.
  • < 640: the popover becomes a fixed card pinned 12px from the sides and 16px from the bottom, no arrow. Before measuring, .main is scrolled so the target sits about 72px from the top, clear of the card. Table rows collapse to client and status on the left, amount on the right.

Acceptance checklist

Always

  • The dim, the hole and the ring are one element with a 9999px spread shadow, moved by transform and resized by width/height.
  • Between stops the cutout and popover glide together over 460ms; the cutout radius follows the target radius + padding.
  • Popover text cross-fades (out 140ms, in 200ms) instead of changing mid-move.
  • Placement prefers the stop’s side, falls back when there is no room, and is clamped 16px inside the viewport; the arrow still points at the target’s centre.
  • The target is scrolled into view before it is measured, and the stop is re-placed on resize and after fonts load.
  • A step count (“Stop 2 of 4”) is visible and announced.
  • Skip, Finish and Escape all end the tour, a toast says where to restart it, and focus lands on the restart control.
  • At 390px the popover is a bottom card and the target is visible above it; nothing scrolls sideways.
  • Reduced motion keeps every stop and removes all movement.

This demo

  • Stops: New invoice, Overdue 2, “Viewed twice” on INV-0148 (Holm & Daughter, £4,860.00), Next payout £3,412.60 on Friday 30 October.
  • Popover is 320px, #1d1c19, with four 18 × 3px pips and a saffron primary.
  • Every figure in the app and the popover is set in Space Grotesk.

Implementation notes

One box for dim, hole and ring. Move it with transform; never animate top/left on it.

.spot { position: absolute; left: 0; top: 0; pointer-events: none;
  box-shadow: 0 0 0 2px var(--ring), 0 0 0 9999px var(--scrim);
  transition: transform 460ms var(--ease-out), width 460ms var(--ease-out),
              height 460ms var(--ease-out), border-radius 460ms var(--ease-out); }

Measure, choose a side, clamp, aim the arrow.

const r = el.getBoundingClientRect();
const x = r.left - PAD, y = r.top - PAD, w = r.width + PAD * 2, h = r.height + PAD * 2;
spot.style.transform = `translate(${x}px,${y}px)`; spot.style.width = w + 'px'; spot.style.height = h + 'px';
let side = s.side;
if (side === 'left' && x - GAP - tw < M) side = 'bottom';
if (side === 'bottom' && y + h + GAP + th > innerHeight - M) side = 'top';
let tx = side === 'left' ? x - GAP - tw : x + w / 2 - tw / 2;
let ty = side === 'left' ? y + h / 2 - th / 2 : side === 'bottom' ? y + h + GAP : y - GAP - th;
tx = Math.max(M, Math.min(innerWidth - M - tw, tx)); ty = Math.max(M, Math.min(innerHeight - M - th, ty));
tip.style.transform = `translate(${tx}px,${ty}px)`;
arr.style.left = Math.max(14, Math.min(tw - 28, x + w / 2 - tx - 7)) + 'px';

No fly-in on the first frame. Place once with transitions disabled, then re-enable two frames later. Do the same when document.fonts.ready resolves, because the target’s width changes when the web font arrives.

dlg.classList.add('still'); place();
requestAnimationFrame(() => requestAnimationFrame(() => dlg.classList.remove('still')));

Common mistakes: four separate dim panels around the target, which cannot animate radius and leave seams; measuring before the target is scrolled into view; a popover that runs off the right edge on stop 1; copy that describes the control (“This is the New invoice button”) instead of what it does for this account; no way back to the tour after skipping.

Details

Palette
Type
Hubot Sans · Space Grotesk
Motion
Subtle motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

MWDT

A control. It studies this site for quiet, not for a show.

All sources