Focus
import { useEffect, useId, useRef, useState } from 'react';
const TOPICS = [
{ label: 'All', body: 'Everything from the last month, newest first.' },
{ label: 'Design', body: 'Grids, type pairings and a long read on colour.' },
{ label: 'Code', body: 'Small, sharp snippets: easing, layout and canvas tricks.' },
{ label: 'Motion', body: 'Springs, timing and when to leave something still.' },
{ label: 'Type', body: 'New releases, revivals and one very odd italic.' },
];
const W = 240;
const COL = W / TOPICS.length;
const css = `
.tabs-focus { width: ${W}px; }
.tabs-focus__list { position: relative; display: grid; grid-template-columns: repeat(${TOPICS.length}, 1fr); }
.tabs-focus__tab { border: 0; padding: 6px 0 10px; background: none; font: inherit; font-size: 12.5px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); cursor: pointer; }
.tabs-focus__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-focus__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-focus__label { display: inline-block; }
/* A focus mark rides with the plane of focus, like the point a lens is set to. */
.tabs-focus__mark { position: absolute; left: 0; bottom: 2px; width: 18px; height: 6px; margin-left: -9px; border: 1.5px solid light-dark(#373a40, #f1f2f3); border-top: 0; border-radius: 0 0 3px 3px; }
.tabs-focus__panel { padding: 10px 0 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function FocusTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [hover, setHover] = useState<number | null>(null);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const labels = useRef<(HTMLSpanElement | null)[]>([]);
const mark = useRef<HTMLSpanElement>(null);
const st = useRef({ f: 0, v: 0, raf: 0 });
// Depth of field across the row: each label softens with its distance from the plane of focus,
// and racking focus to a tab sweeps that plane across, sharpening each label as it passes.
// Pointing at a tab racks to it for a look; letting go racks back.
useEffect(() => {
const s = st.current;
const goal = hover ?? sel;
const draw = () => {
mark.current!.style.transform = `translateX(${s.f * COL + COL / 2}px)`;
labels.current.forEach((el, i) => {
const d = Math.abs(i - s.f);
el!.style.filter = `blur(${Math.min(0.9, d * 0.6).toFixed(2)}px)`;
el!.style.opacity = String(1 - Math.min(0.2, d * 0.1));
el!.style.transform = `scale(${1 - Math.min(0.08, d * 0.04)})`;
});
};
cancelAnimationFrame(s.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { f: goal, v: 0 });
return draw();
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-110 * (s.f - goal) - 20 * s.v) * dt;
s.f += s.v * dt;
if (Math.abs(s.f - goal) < 0.002 && Math.abs(s.v) < 0.01) {
Object.assign(s, { f: goal, v: 0 });
return draw();
}
draw();
s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel, hover]);
const n = TOPICS.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();
};
const blur = (i: number) => ({
filter: `blur(${Math.min(0.9, i * 0.6).toFixed(2)}px)`,
opacity: 1 - Math.min(0.2, i * 0.1),
transform: `scale(${1 - Math.min(0.08, i * 0.04)})`,
});
return (
<>
<style>
{css}
</style>
<div className="tabs-focus">
<div className="tabs-focus__list" role="tablist" aria-label="Topics" onKeyDown={onKeyDown} onPointerLeave={() => setHover(null)}>
{TOPICS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-focus__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(i)}
onClick={() => setSel(i)}
>
<span
className="tabs-focus__label"
style={blur(i)}
ref={(el) => {
labels.current[i] = el;
}}
>
{t.label}
</span>
</button>
))}
<span className="tabs-focus__mark" ref={mark} style={{ transform: `translateX(${COL / 2}px)` }} aria-hidden="true" />
</div>
{TOPICS.map((t, i) => (
<div key={t.label} className="tabs-focus__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 initTabsFocus(el) on the mounted element instead.
<div class="tabs-focus">
<div class="tabs-focus__list" role="tablist" aria-label="Topics">
<button class="tabs-focus__tab" type="button" role="tab" id="tabs-focus-tab-0" aria-selected="true" aria-controls="tabs-focus-panel-0" tabindex="0">
<span class="tabs-focus__label" style="filter:blur(0.00px);opacity:1;transform:scale(1)">All</span>
</button>
<button class="tabs-focus__tab" type="button" role="tab" id="tabs-focus-tab-1" aria-selected="false" aria-controls="tabs-focus-panel-1" tabindex="-1">
<span class="tabs-focus__label" style="filter:blur(0.60px);opacity:0.9;transform:scale(0.96)">Design</span>
</button>
<button class="tabs-focus__tab" type="button" role="tab" id="tabs-focus-tab-2" aria-selected="false" aria-controls="tabs-focus-panel-2" tabindex="-1">
<span class="tabs-focus__label" style="filter:blur(0.90px);opacity:0.8;transform:scale(0.92)">Code</span>
</button>
<button class="tabs-focus__tab" type="button" role="tab" id="tabs-focus-tab-3" aria-selected="false" aria-controls="tabs-focus-panel-3" tabindex="-1">
<span class="tabs-focus__label" style="filter:blur(0.90px);opacity:0.8;transform:scale(0.92)">Motion</span>
</button>
<button class="tabs-focus__tab" type="button" role="tab" id="tabs-focus-tab-4" aria-selected="false" aria-controls="tabs-focus-panel-4" tabindex="-1">
<span class="tabs-focus__label" style="filter:blur(0.90px);opacity:0.8;transform:scale(0.92)">Type</span>
</button>
<span class="tabs-focus__mark" style="transform:translateX(24px)" aria-hidden="true"></span>
</div>
<div class="tabs-focus__panel" role="tabpanel" id="tabs-focus-panel-0" aria-labelledby="tabs-focus-tab-0">Everything from the last month, newest first.</div>
<div class="tabs-focus__panel" role="tabpanel" id="tabs-focus-panel-1" aria-labelledby="tabs-focus-tab-1" hidden="">Grids, type pairings and a long read on colour.</div>
<div class="tabs-focus__panel" role="tabpanel" id="tabs-focus-panel-2" aria-labelledby="tabs-focus-tab-2" hidden="">Small, sharp snippets: easing, layout and canvas tricks.</div>
<div class="tabs-focus__panel" role="tabpanel" id="tabs-focus-panel-3" aria-labelledby="tabs-focus-tab-3" hidden="">Springs, timing and when to leave something still.</div>
<div class="tabs-focus__panel" role="tabpanel" id="tabs-focus-panel-4" aria-labelledby="tabs-focus-tab-4" hidden="">New releases, revivals and one very odd italic.</div>
</div>.tabs-focus { width: 240px; }
.tabs-focus__list { position: relative; display: grid; grid-template-columns: repeat(5, 1fr); }
.tabs-focus__tab { border: 0; padding: 6px 0 10px; background: none; font: inherit; font-size: 12.5px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); cursor: pointer; }
.tabs-focus__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-focus__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-focus__label { display: inline-block; }
/* A focus mark rides with the plane of focus, like the point a lens is set to. */
.tabs-focus__mark { position: absolute; left: 0; bottom: 2px; width: 18px; height: 6px; margin-left: -9px; border: 1.5px solid light-dark(#373a40, #f1f2f3); border-top: 0; border-radius: 0 0 3px 3px; }
.tabs-focus__panel { padding: 10px 0 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsFocus(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var COL = 240 / n;
var labels = root.querySelectorAll('.tabs-focus__label');
var mark = root.querySelector('.tabs-focus__mark');
var hover = null;
var s = { f: sel, v: 0 };
var raf = 0;
function draw() {
mark.style.transform = 'translateX(' + (s.f * COL + COL / 2) + 'px)';
labels.forEach(function (el, i) {
var d = Math.abs(i - s.f);
el.style.filter = 'blur(' + Math.min(0.9, d * 0.6).toFixed(2) + 'px)';
el.style.opacity = String(1 - Math.min(0.2, d * 0.1));
el.style.transform = 'scale(' + (1 - Math.min(0.08, d * 0.04)) + ')';
});
}
// Depth of field across the row: each label softens with its distance from the plane of focus,
// and racking focus to a tab sweeps that plane across, sharpening each label as it passes.
// Pointing at a tab racks to it for a look; letting go racks back.
function rack() {
cancelAnimationFrame(raf);
var goal = hover === null ? sel : hover;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { f: goal, v: 0 };
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-110 * (s.f - goal) - 20 * s.v) * dt;
s.f += s.v * dt;
if (Math.abs(s.f - goal) < 0.002 && Math.abs(s.v) < 0.01) {
s = { f: goal, v: 0 };
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function onEnter(e) {
if (e.pointerType !== 'mouse') return;
hover = tabs.indexOf(e.currentTarget);
rack();
}
function onLeave() {
hover = null;
rack();
}
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; });
rack();
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
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);
tabs.forEach(function (tab) { tab.addEventListener('pointerenter', onEnter); });
list.addEventListener('pointerleave', onLeave);
return function () {
cancelAnimationFrame(raf);
tabs.forEach(function (tab) { tab.removeEventListener('pointerenter', onEnter); });
list.removeEventListener('pointerleave', onLeave);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-focus').forEach(initTabsFocus);Goal: Create a "Focus" tab set.
Visual behavior: Topic tabs with depth of field: labels soften with distance from the chosen one, and switching racks focus across the row; pointing at a tab racks to it for a look.
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.
- A plane of focus moves along the row on a damped spring. Each label's blur, opacity and scale come from its distance to that plane, so racking focus sharpens each label as it passes.
- Pointing at a tab with a mouse racks focus to it for a look; leaving the row racks back to the chosen tab.
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. The blur stays slight, so every label remains readable. Under prefers-reduced-motion focus moves at once.
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 "Focus" tab set.
Visual behavior: Topic tabs with depth of field: labels soften with distance from the chosen one, and switching racks focus across the row; pointing at a tab racks to it for a look.
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.
- A plane of focus moves along the row on a damped spring. Each label's blur, opacity and scale come from its distance to that plane, so racking focus sharpens each label as it passes.
- Pointing at a tab with a mouse racks focus to it for a look; leaving the row racks back to the chosen tab.
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. The blur stays slight, so every label remains readable. Under prefers-reduced-motion focus moves at once.
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
Topic tabs with depth of field: labels soften with distance from the chosen one, and switching racks focus across the row; pointing at a tab racks to it for a look.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, hover, state change, spring
- Techniques
- raf, spring, filter
- 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. The blur stays slight, so every label remains readable. Under prefers-reduced-motion focus moves at once.