Design Lounge
Install

Nº 677 of 677

Lantern alarm wheel time picker

Hour, minute and AM/PM wheels on a real 3D cylinder, with fling momentum, detent snapping, a ticking selection band and keys, inside an alarm editor.

Preview

Open full size
390 × 844 · 12.8 KB · 146 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 "Lantern alarm wheel time picker" (piece phone-picker-wheel-time) 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

189 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 edit screen of a fictional bedside alarm app, Lantern, built around a wheel time picker done properly. Three wheels (hour 1–12, minute 00–59, AM/PM) are true 3D cylinders: each row is rotated 18° per step around a 139px radius under a 900px perspective, so rows lean away and foreshorten instead of just fading. You can drag them, fling them and they glide to a stop exactly on a detent; every detent crossed flashes the two small marks at the ends of the selection band in tomato, a visual stand-in for a haptic tick. Below the picker: “Rings tomorrow in 8 h 22 min”, seven repeat-day toggles with a “Weekdays” summary, a label field, sound and snooze rows, and Delete. Warm paper, a book serif for the numerals and a condensed grotesk for labels. The detail worth copying: momentum and snapping are one curve, a cubic ease-out from the release point to the projected detent, so the wheel never coasts and then visibly corrects.

Reference behaviour

  1. First frame: 6 : 40 AM in the band; three rows above and below each wheel visible on the curve. “Rings tomorrow in 8 h 22 min” (now is Thursday 22:18). Repeat M T W T F filled, S S outlined, summary “Weekdays”. Label “Ferry to Ilha Verde”. Sound “Harbour bell”. Snooze on.
  2. Drag a wheel: pointer capture; position = start − dy / 44 (one row per 44px), drawn every move. Hour and minute wrap; AM/PM rubber-bands past its ends at one-third of the drag.
  3. Release: velocity is measured over the last 100ms of moves (rows per ms). Target = round(position + v × 320). Duration = clamp(|target − position| × 70 + 200, 240, 1400)ms, eased 1 − (1 − k)³. AM/PM targets clamp to 0–1. If the finger was still for more than 80ms before release, it just settles to the nearest row in 220ms.
  4. Each time the rounded position changes (while dragging or gliding), the band plays a 120ms tick: it squashes to scaleY 0.94 and both end marks turn #B83D27 for 130ms.
  5. The centre row is weight 600 in --ink; others are 400 in --dim. Rows past ±66° are hidden.
  6. Keyboard on a focused wheel: ↑/↓ one row (170ms), PageUp/PageDown 5 minutes on the minute wheel (one row elsewhere), Home/End first/last value, ←/→ move focus to the neighbouring wheel. Mouse wheel / trackpad: every 40px of deltaY moves one row (160ms).
  7. After each settle, the “rings” line recomputes: the next enabled day at the chosen time, phrased “later today”, “tomorrow” or “on Monday”, with hours and minutes. No repeat days means the next occurrence of that time.
  8. Repeat toggles flip aria-pressed, press-squash to 0.88 over 260ms, and update the summary: Weekdays, Weekends, Every day, Never, or “Mon Wed Fri”.
  9. Sound row cycles Harbour bell → Low reed → Morning gull → Paper chime. Snooze is a switch. Save announces “Alarm saved for 6:40 AM, Weekdays”.

Structure

390 × 844
┌──────────────────────────────────────┐
│ 54px clear                           │
│ Cancel      EDIT ALARM       (SAVE)  │ top 56h: 88 / 1fr / 88
│ ┌──────────────────────────────────┐ │ picker card 284h, r24, #FBF7F0
│ │          3      37               │ │ wheels 264h: hour 96, minute 96,
│ │          4      38               │ │ period 80; colon 14
│ │          5      39               │ │
│ │ ▌        6  :   40     AM      ▐ │ │ band: inset 12, 44h, r14, #E6DCCB
│ │          7      41     PM        │ │ end marks 3px at 8px from edges
│ │          8      42               │ │
│ └──────────────────────────────────┘ │
│ Rings tomorrow in 8 h 22 min         │ 15px, value 600
│ REPEAT                     Weekdays  │ 12px Oswald, 0.16em
│ (S)(M)(T)(W)(T)(F)(S)                │ 44px circles, space-between
│ LABEL                                │
│ [ Ferry to Ilha Verde            ]   │ input 52h r16
│ ┌ Sound            Harbour bell › ┐  │ list rows 56h, r20 card
│ └ Snooze, 9 min            (●══)  ┘  │ switch 52 × 32
│ [          Delete alarm          ]   │ 48h, margin-top auto
└──────────────────────────────────────┘
  • div.pick[role=group][aria-label="Alarm time"] with div.band[aria-hidden], three div.wheel[role=spinbutton][tabindex=0] and an aria-hidden colon.
  • Each wheel holds a pool of 11 div.it[aria-hidden] rows that are re-labelled and re-transformed every frame (slot −5…+5 around the rounded position).
  • p.rings[aria-live=polite], h2 labels, div.days[role=group] of seven button[aria-pressed], a labelled input, div.list with a button.row and a button.sw[role=switch], button.del.

Tokens

:root {
  --bg: #F4EEE4;        /* page */
  --card: #FBF7F0;      /* picker card, list, input */
  --band: #E6DCCB;      /* selection band */
  --band-tick: #D3C3A8; /* band end marks at rest */
  --line: #DDD2C1;      /* card borders, row dividers */
  --ink: #2A2320;       /* centre row, filled days, switch on */
  --ink-2: #5E534A;     /* labels, secondary */
  --dim: #6A5F55;       /* off-centre rows, 5.0:1 on the card */
  --tom: #B83D27;       /* Save, Cancel, ticks, focus */
  --tom-ink: #FFFFFF;
  --edge: #8C7F70;      /* input, day and switch borders, 3.4:1 */
  --serif: "Literata", Georgia, serif;
  --cond: "Oswald", "Arial Narrow", sans-serif;
  --fs-wheel: 27px; --fs-period: 20px; --fs-colon: 28px; --fs-body: 16px; --fs-input: 17px; --fs-rings: 15px; --fs-label: 12px;
  --item: 44px; --step: 18deg; --radius: 139px;   /* 22 / tan(9deg) */
  --perspective: 900px; --wheel-h: 264px; --hide-beyond: 66deg;
  --r-card: 24px; --r-band: 14px; --r-input: 16px; --r-list: 20px; --r-pill: 999px;
  --ease: cubic-bezier(.2,.7,.2,1);
  --expo: cubic-bezier(.16,1,.3,1);
  --glide: cubic(1 - (1-k)^3); --t-tick: 120ms; --t-key: 170ms; --t-settle: 220ms;
  --fling-gain: 320; --ms-per-row: 70;
}

Typography

RoleFamilySizeWeightNotes
Wheel numeralsLiterata, opsz 4027px400; centre 600lining tabular figures
ColonLiterata28px600nudged up 2px
AM / PMOswald20px5000.08em
Title EDIT ALARM, SAVEOswald15px500 / 6000.16em / 0.08em, upper
Section labelsOswald12px5000.16em, upper; summary 13px 0.06em
Day lettersOswald15px5000.04em
Body, rows, inputLiterata16–17px400–5001.35

Motion

WhatTriggerFrom → toDuration / easingReduced motion
Wheel dragpointer move1 row per 44px, 18° per row1:1same
Fling + snapreleaseposition → round(pos + v × 320)clamp(Δ × 70 + 200, 240, 1400)ms, cubic outjumps to the nearest row, no fling
Key / wheel stepkeys, mouse wheel±1 or ±5 rows170ms / 160ms cubic outinstant
Detent tickeach row crossedband scaleY .94, end marks tomato120ms --easenone
Day togglepressscale .88 → 1, fill 160ms260ms --expofill only
Switchpressknob translateX 0 → 20px240ms --expoinstant

States

  • Resting: band marks --band-tick; centre row bold.
  • Moving: marks flash per detent; the bold row changes as each row crosses centre.
  • AM/PM edge: dragging past AM or PM resists at one third and glides back.
  • Repeat on / off: --ink fill with --bg letter / 1.5px --edge outline.
  • Snooze on / off: --ink track, knob right / #CFC4B4 track, knob left.
  • Focus-visible: 3px --tom outline (inset 2px on wheels so it stays inside the card).

Accessibility

  • Each wheel is role="spinbutton" with aria-label (Hour, Minute, AM or PM), aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext (“40”, “AM”). Rows are aria-hidden.
  • Keys as in Reference behaviour 6; ←/→ hop between wheels so the whole time can be set without Tab.
  • The live region says “Minute 52” after each settle, the sound change and the save; “Rings…” is its own polite region.
  • Days have full names as labels (“Monday”) and aria-pressed; the group is labelled by “Repeat”.
  • The snooze switch is labelled by its row text.
  • Contrast: off-centre rows use a solid colour, not opacity, so every visible row is at least 5:1; white on --tom 5.6:1; borders 3.4:1.
  • Hit targets: wheels 96 × 264, days 44px, switch 52 × 32 inside a 56px row, Save 40px tall, Delete 48px.

Responsive rules

  • The column caps at 440px; the picker card fills it and the wheels stay 96 / 96 / 80 centred.
  • At 360 wide the days stay 44px and the gaps shrink; the wheels keep their widths (total 288px).
  • If height is short (under 740px), drop the list card above Delete, not the picker.
  • For a 24-hour locale, render 00–23 in the hour wheel and remove the period wheel.

Acceptance checklist

Always

  • Rows are placed with translateZ(−R) rotateX(a) translateZ(R) at 18° per row under a 900px perspective, R = 22 / tan 9°.
  • A pool of 11 row nodes per wheel is reused; hour and minute wrap.
  • Release projects velocity (last 100ms) to a target detent and glides there in one cubic-out curve.
  • Every detent crossed triggers a visible tick on the band.
  • The period wheel rubber-bands at its ends and clamps.
  • Off-centre rows are dimmed with colour, never opacity, and hidden past 66°.
  • ↑ ↓ PageUp PageDown Home End and ← → work; wheel events step one row per 40px.
  • Each wheel exposes spinbutton value and text.
  • Repeat summary covers Weekdays, Weekends, Every day, Never and lists.
  • Reduced motion: no glide, no tick; values still change.
  • Scroll width is 390 and nothing fixed sits in the top 54px.

This demo

  • Opens at 6:40 AM, “Rings tomorrow in 8 h 22 min” with now Thursday 22:18.
  • Repeat Monday to Friday, label “Ferry to Ilha Verde”, sound “Harbour bell”, snooze 9 min on.

Implementation notes

Draw a wheel from a fractional position. Slots −5…+5 around the rounded position, each placed on the cylinder by its angle from the true position:

const R = 22 / Math.tan(9 * Math.PI / 180);   // 138.9px for 44px rows at 18deg
function draw(w) {
  const base = Math.round(w.pos), n = w.vals.length;
  w.nodes.forEach((d, s) => {
    const i = base + s - 5, a = (i - w.pos) * 18, ok = w.loop || (i >= 0 && i < n);
    if (!ok || Math.abs(a) > 66) { d.style.visibility = 'hidden'; return; }
    d.style.visibility = ''; d.textContent = w.vals[((i % n) + n) % n];
    d.style.transform = `translateZ(${-R}px) rotateX(${-a}deg) translateZ(${R}px)`;
  });
  if (base !== w.last) { w.last = base; tick(); }
}

Momentum and snap as one curve.

const v = (last.pos - first.pos) / Math.max(16, last.t - first.t);   // rows per ms over 100ms
const to = Math.round(w.pos + v * 320);
const ms = Math.max(240, Math.min(1400, Math.abs(to - w.pos) * 70 + 200));
glide(w, to, ms);   // rAF, pos = from + (to - from) * (1 - (1 - k) ** 3)

The tick needs a restart. Remove the class, force a reflow, add it again, or fast flings only flash once:

band.classList.remove('tk'); void band.offsetWidth; band.classList.add('tk');

Common mistakes: a CSS scroll-snap list with a gradient mask (no real curvature, and the masked rows fail contrast), simulating friction and then snapping separately (a visible correction at the end), creating 60 DOM rows per wheel, and forgetting user-select: none so a drag selects the numbers.

Details

Palette
Type
Literata · Oswald
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

Icon Museum

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

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.