Ticks
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Inbox', body: 'Nine new messages, two of them flagged for today.' },
{ label: 'Drafts', body: 'Three drafts, the oldest from last Tuesday.' },
{ label: 'Sent', body: 'Forty-two sent this week; four are waiting on a reply.' },
{ label: 'Archive', body: 'Everything you have filed away, searchable by sender.' },
];
const W = 264;
const COL = W / TABS.length;
const PITCH = 4;
const COUNT = Math.floor(W / PITCH);
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
const css = `
.tabs-ticks { width: ${W}px; }
.tabs-ticks__list { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); }
.tabs-ticks__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-ticks__tab:hover, .tabs-ticks__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ticks__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-ticks__comb { display: flex; justify-content: space-between; align-items: flex-end; height: 14px; padding: 0 1px; }
.tabs-ticks__tick { width: 1px; height: 4px; border-radius: 0.5px; background: light-dark(#dbdde1, #2e3035); }
.tabs-ticks__panel { padding: 14px 0 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function TicksTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const ticks = useRef<(HTMLSpanElement | null)[]>([]);
const st = useRef({ c: sel * COL + COL / 2, v: 0, raf: 0 });
// The first render draws the resting crest; after that the animation owns the ticks' styles.
const first = useRef(sel).current;
// A crest the width of a tab: ticks inside it stand tall and bright, with soft shoulders at its edges.
const draw = (c: number, speed: number) => {
const half = COL / 2 - 8 + Math.min(18, Math.abs(speed) * 0.025);
ticks.current.forEach((t, k) => {
const f = 1 - smooth(half - 6, half + 6, Math.abs(k * PITCH + 1.5 - c));
t!.style.height = `${4 + 9 * f}px`;
t!.style.background = `color-mix(in oklab, light-dark(#373a40, #f1f2f3) ${Math.round(f * 100)}%, light-dark(#dbdde1, #2e3035))`;
});
};
useEffect(() => {
const s = st.current;
const goal = sel * COL + COL / 2;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s.c = goal;
draw(goal, 0);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-170 * (s.c - goal) - 23 * s.v) * dt;
s.c += s.v * dt;
draw(s.c, s.v);
if (Math.abs(s.c - goal) < 0.05 && Math.abs(s.v) < 0.5) {
Object.assign(s, { c: goal, v: 0 });
draw(goal, 0);
return;
}
s.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(s.raf);
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-ticks">
<div className="tabs-ticks__list" role="tablist" aria-label="Mail" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-ticks__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{t.label}
</button>
))}
</div>
<div className="tabs-ticks__comb" aria-hidden="true">
{Array.from({ length: COUNT }, (_, k) => {
const f = 1 - smooth(COL / 2 - 14, COL / 2 - 2, Math.abs(k * PITCH + 1.5 - (first * COL + COL / 2)));
return (
<span
key={k}
ref={(el) => {
ticks.current[k] = el;
}}
className="tabs-ticks__tick"
style={{ height: 4 + 9 * f, background: `color-mix(in oklab, light-dark(#373a40, #f1f2f3) ${Math.round(f * 100)}%, light-dark(#dbdde1, #2e3035))` }}
/>
);
})}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-ticks__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsTicks(el) on the mounted element instead.
<div class="tabs-ticks">
<div class="tabs-ticks__list" role="tablist" aria-label="Mail">
<button class="tabs-ticks__tab" type="button" role="tab" id="tabs-ticks-tab-0" aria-selected="true" aria-controls="tabs-ticks-panel-0" tabindex="0">Inbox</button>
<button class="tabs-ticks__tab" type="button" role="tab" id="tabs-ticks-tab-1" aria-selected="false" aria-controls="tabs-ticks-panel-1" tabindex="-1">Drafts</button>
<button class="tabs-ticks__tab" type="button" role="tab" id="tabs-ticks-tab-2" aria-selected="false" aria-controls="tabs-ticks-panel-2" tabindex="-1">Sent</button>
<button class="tabs-ticks__tab" type="button" role="tab" id="tabs-ticks-tab-3" aria-selected="false" aria-controls="tabs-ticks-panel-3" tabindex="-1">Archive</button>
</div>
<div class="tabs-ticks__comb" aria-hidden="true">
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:5.85px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 21%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:10.152px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 68%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:12.954px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 99%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:13px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 100%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:12.613px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 96%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:9.061px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 56%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:5.009px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 11%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
<span class="tabs-ticks__tick" style="height:4px;background:color-mix(in oklab, light-dark(#373a40, #f1f2f3) 0%, light-dark(#dbdde1, #2e3035))"></span>
</div>
<div class="tabs-ticks__panel" role="tabpanel" id="tabs-ticks-panel-0" aria-labelledby="tabs-ticks-tab-0">Nine new messages, two of them flagged for today.</div>
<div class="tabs-ticks__panel" role="tabpanel" id="tabs-ticks-panel-1" aria-labelledby="tabs-ticks-tab-1" hidden="">Three drafts, the oldest from last Tuesday.</div>
<div class="tabs-ticks__panel" role="tabpanel" id="tabs-ticks-panel-2" aria-labelledby="tabs-ticks-tab-2" hidden="">Forty-two sent this week; four are waiting on a reply.</div>
<div class="tabs-ticks__panel" role="tabpanel" id="tabs-ticks-panel-3" aria-labelledby="tabs-ticks-tab-3" hidden="">Everything you have filed away, searchable by sender.</div>
</div>.tabs-ticks { width: 264px; }
.tabs-ticks__list { display: grid; grid-template-columns: repeat(4, 1fr); }
.tabs-ticks__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-ticks__tab:hover, .tabs-ticks__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ticks__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-ticks__comb { display: flex; justify-content: space-between; align-items: flex-end; height: 14px; padding: 0 1px; }
.tabs-ticks__tick { width: 1px; height: 4px; border-radius: 0.5px; background: light-dark(#dbdde1, #2e3035); }
.tabs-ticks__panel { padding: 14px 0 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsTicks(root) {
var W = 264;
var PITCH = 4;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var ticks = root.querySelectorAll('.tabs-ticks__tick');
var COL = W / tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var c = sel * COL + COL / 2;
var v = 0;
var raf = 0;
function smooth(a, b, x) {
var t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// A crest the width of a tab: ticks inside it stand tall and bright, with soft shoulders at its edges.
// It stretches a little with speed.
function draw() {
var half = COL / 2 - 8 + Math.min(18, Math.abs(v) * 0.025);
ticks.forEach(function (t, k) {
var f = 1 - smooth(half - 6, half + 6, Math.abs(k * PITCH + 1.5 - c));
t.style.height = 4 + 9 * f + 'px';
t.style.background = 'color-mix(in oklab, light-dark(#373a40, #f1f2f3) ' + Math.round(f * 100) + '%, light-dark(#dbdde1, #2e3035))';
});
}
function run() {
cancelAnimationFrame(raf);
var goal = sel * COL + COL / 2;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
c = goal;
v = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-170 * (c - goal) - 23 * v) * dt;
c += v * dt;
if (Math.abs(c - goal) < 0.05 && Math.abs(v) < 0.5) {
c = goal;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function select(i) {
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; });
run();
}
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 () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-ticks').forEach(initTabsTicks);Goal: Create a "Ticks" tab set.
Visual behavior: Mail tabs over a row of fine ticks: the ticks under the active tab stand tall and bright, and switching sends that crest travelling across to the new one.
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.
- Sixty-six 1px ticks sit under the tabs. A crest the width of a tab, with soft shoulders, sets each tick's height and colour from its distance to the crest's centre.
- The centre moves to the new tab on a spring, and its speed widens the crest a little, so it stretches as it travels.
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 crest moves straight to the new tab.
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 "Ticks" tab set.
Visual behavior: Mail tabs over a row of fine ticks: the ticks under the active tab stand tall and bright, and switching sends that crest travelling across to the new one.
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.
- Sixty-six 1px ticks sit under the tabs. A crest the width of a tab, with soft shoulders, sets each tick's height and colour from its distance to the crest's centre.
- The centre moves to the new tab on a spring, and its speed widens the crest a little, so it stretches as it travels.
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 crest moves straight to the new tab.
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
Mail tabs over a row of fine ticks: the ticks under the active tab stand tall and bright, and switching sends that crest travelling across to the new one.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- Techniques
- raf, spring, css variables
- 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 crest moves straight to the new tab.