Clock
import { useId, useRef, useState } from 'react';
const CITIES = [
{ label: 'London', time: 9 * 60 + 30, note: 'Morning. The office is filling up.' },
{ label: 'Tokyo', time: 17 * 60 + 30, note: 'Evening. Eight hours ahead of London.' },
{ label: 'New York', time: 4 * 60 + 30, note: 'Before dawn. Wait a few hours to call.' },
];
const TICKS = Array.from({ length: 12 }, (_, i) => i);
const hhmm = (t: number) => `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
const css = `
.tabs-clock { width: 260px; }
.tabs-clock__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 14px; }
.tabs-clock__tab { padding: 7px 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: color 0.2s, background-color 0.2s; }
.tabs-clock__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-clock__tab[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
.tabs-clock__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-clock__row { display: flex; align-items: center; gap: 16px; }
.tabs-clock__face { position: relative; flex: none; width: 96px; height: 96px; border-radius: 50%; background: radial-gradient(circle, light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 2px light-dark(#cbced3, #393c41), 0 10px 20px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.tabs-clock__tick { position: absolute; left: 50%; top: 50%; width: 2px; height: 7px; margin: -3.5px -1px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); transform: rotate(calc(var(--k) * 30deg)) translateY(-38px); }
.tabs-clock__hand { position: absolute; left: 50%; bottom: 50%; border-radius: 2px; transform-origin: 50% 100%; transition: transform 1.2s cubic-bezier(0.5, 0, 0.2, 1); }
.tabs-clock__hand--h { width: 4px; height: 24px; margin-left: -2px; background: light-dark(#373a40, #f1f2f3); transform: rotate(calc(var(--tabs-clock-t) * 0.5deg)); }
.tabs-clock__hand--m { width: 2px; height: 36px; margin-left: -1px; background: light-dark(#50535b, #c6c8cd); transform: rotate(calc(var(--tabs-clock-t) * 6deg)); }
.tabs-clock__pin { position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529); }
.tabs-clock__panel { flex: 1; }
.tabs-clock__time { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 26px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.tabs-clock__note { color: light-dark(#50535b, #c6c8cd); font-size: 12px; line-height: 1.45; }
@media (prefers-reduced-motion: reduce) {
.tabs-clock__hand { transition: none; }
}
`;
export function ClockTabs() {
const id = useId();
const [state, setState] = useState({ sel: 0, total: CITIES[0].time });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const { sel } = state;
const select = (i: number) => {
if (i === sel) return;
// Always wind forward to the new time, so the minute hand whirls through the hours in between.
setState((s) => ({ sel: i, total: s.total + ((((CITIES[i].time - s.total) % 720) + 720) % 720 || 720) }));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = CITIES.length;
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-clock" style={{ '--tabs-clock-t': state.total } as React.CSSProperties}>
<div className="tabs-clock__list" role="tablist" aria-label="City" onKeyDown={onKeyDown}>
{CITIES.map((c, i) => (
<button
key={c.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-clock__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{c.label}
</button>
))}
</div>
<div className="tabs-clock__row">
<span className="tabs-clock__face" aria-hidden="true">
{TICKS.map((k) => (
<span key={k} className="tabs-clock__tick" style={{ '--k': k } as React.CSSProperties} />
))}
<span className="tabs-clock__hand tabs-clock__hand--h" />
<span className="tabs-clock__hand tabs-clock__hand--m" />
<span className="tabs-clock__pin" />
</span>
{CITIES.map((c, i) => (
<div key={c.label} className="tabs-clock__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<span className="tabs-clock__time">{hhmm(c.time)}</span>
<span className="tabs-clock__note">{c.note}</span>
</div>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsClock(el) on the mounted element instead.
<div class="tabs-clock" style="--tabs-clock-t:570">
<div class="tabs-clock__list" role="tablist" aria-label="City">
<button class="tabs-clock__tab" type="button" role="tab" id="tabs-clock-tab-0" aria-selected="true" aria-controls="tabs-clock-panel-0" tabindex="0">London</button>
<button class="tabs-clock__tab" type="button" role="tab" id="tabs-clock-tab-1" aria-selected="false" aria-controls="tabs-clock-panel-1" tabindex="-1">Tokyo</button>
<button class="tabs-clock__tab" type="button" role="tab" id="tabs-clock-tab-2" aria-selected="false" aria-controls="tabs-clock-panel-2" tabindex="-1">New York</button>
</div>
<div class="tabs-clock__row">
<span class="tabs-clock__face" aria-hidden="true">
<span class="tabs-clock__tick" style="--k:0"></span>
<span class="tabs-clock__tick" style="--k:1"></span>
<span class="tabs-clock__tick" style="--k:2"></span>
<span class="tabs-clock__tick" style="--k:3"></span>
<span class="tabs-clock__tick" style="--k:4"></span>
<span class="tabs-clock__tick" style="--k:5"></span>
<span class="tabs-clock__tick" style="--k:6"></span>
<span class="tabs-clock__tick" style="--k:7"></span>
<span class="tabs-clock__tick" style="--k:8"></span>
<span class="tabs-clock__tick" style="--k:9"></span>
<span class="tabs-clock__tick" style="--k:10"></span>
<span class="tabs-clock__tick" style="--k:11"></span>
<span class="tabs-clock__hand tabs-clock__hand--h"></span>
<span class="tabs-clock__hand tabs-clock__hand--m"></span>
<span class="tabs-clock__pin"></span>
</span>
<div class="tabs-clock__panel" role="tabpanel" id="tabs-clock-panel-0" aria-labelledby="tabs-clock-tab-0">
<span class="tabs-clock__time">09:30</span>
<span class="tabs-clock__note">Morning. The office is filling up.</span>
</div>
<div class="tabs-clock__panel" role="tabpanel" id="tabs-clock-panel-1" aria-labelledby="tabs-clock-tab-1" hidden="">
<span class="tabs-clock__time">17:30</span>
<span class="tabs-clock__note">Evening. Eight hours ahead of London.</span>
</div>
<div class="tabs-clock__panel" role="tabpanel" id="tabs-clock-panel-2" aria-labelledby="tabs-clock-tab-2" hidden="">
<span class="tabs-clock__time">04:30</span>
<span class="tabs-clock__note">Before dawn. Wait a few hours to call.</span>
</div>
</div>
</div>.tabs-clock { width: 260px; }
.tabs-clock__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 14px; }
.tabs-clock__tab { padding: 7px 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: color 0.2s, background-color 0.2s; }
.tabs-clock__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-clock__tab[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); }
.tabs-clock__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-clock__row { display: flex; align-items: center; gap: 16px; }
.tabs-clock__face { position: relative; flex: none; width: 96px; height: 96px; border-radius: 50%; background: radial-gradient(circle, light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 2px light-dark(#cbced3, #393c41), 0 10px 20px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
.tabs-clock__tick { position: absolute; left: 50%; top: 50%; width: 2px; height: 7px; margin: -3.5px -1px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); transform: rotate(calc(var(--k) * 30deg)) translateY(-38px); }
.tabs-clock__hand { position: absolute; left: 50%; bottom: 50%; border-radius: 2px; transform-origin: 50% 100%; transition: transform 1.2s cubic-bezier(0.5, 0, 0.2, 1); }
.tabs-clock__hand--h { width: 4px; height: 24px; margin-left: -2px; background: light-dark(#373a40, #f1f2f3); transform: rotate(calc(var(--tabs-clock-t) * 0.5deg)); }
.tabs-clock__hand--m { width: 2px; height: 36px; margin-left: -1px; background: light-dark(#50535b, #c6c8cd); transform: rotate(calc(var(--tabs-clock-t) * 6deg)); }
.tabs-clock__pin { position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529); }
.tabs-clock__panel { flex: 1; }
.tabs-clock__time { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 26px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.tabs-clock__note { color: light-dark(#50535b, #c6c8cd); font-size: 12px; line-height: 1.45; }
@media (prefers-reduced-motion: reduce) {
.tabs-clock__hand { transition: none; }
}function initTabsClock(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var TIMES = [9 * 60 + 30, 17 * 60 + 30, 4 * 60 + 30];
var total = TIMES[sel];
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
// Always wind forward to the new time, so the minute hand whirls through the hours in between.
total += (((TIMES[i] - total) % 720) + 720) % 720 || 720;
root.style.setProperty('--tabs-clock-t', total);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-clock').forEach(initTabsClock);Goal: Create a "Clock" tab set.
Visual behavior: A world clock as tabs: pick a city and the clock hands spin forward to its local time, the minute hand whirling through the hours in between.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The time is kept as a running total of minutes that only ever grows: each change adds the forward distance to the new time (mod 12 hours), so the hands never wind backwards.
- One CSS custom property holds that total; the hour hand rotates by half a degree per minute and the minute hand by six, both on one long transition.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the hands jump straight to the time.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Clock" tab set.
Visual behavior: A world clock as tabs: pick a city and the clock hands spin forward to its local time, the minute hand whirling through the hours in between.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The time is kept as a running total of minutes that only ever grows: each change adds the forward distance to the new time (mod 12 hours), so the hands never wind backwards.
- One CSS custom property holds that total; the hour hand rotates by half a degree per minute and the minute hand by six, both on one long transition.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the hands jump straight to the time.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A world clock as tabs: pick a city and the clock hands spin forward to its local time, the minute hand whirling through the hours in between.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- css variables, css transition, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the hands jump straight to the time.