Flock
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{ label: 'Today', body: 'Two deliveries arriving before noon and one to send.' },
{ label: 'Week', body: 'Fourteen orders this week, three of them still to pack.' },
{ label: 'Month', body: 'Sales are up 12% on last month, led by the spring range.' },
{ label: 'Year', body: 'The best year yet: 1,840 orders from 31 countries.' },
];
const DOTS = 6;
const SPACE = 6;
// Every bird takes its own height of arc.
const LIFT = [10, 17, 8, 14, 20, 11];
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const css = `
.tabs-flock { width: 270px; }
.tabs-flock__list { position: relative; display: flex; gap: 22px; padding-bottom: 12px; }
.tabs-flock__tab { border: 0; padding: 6px 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-flock__tab:hover, .tabs-flock__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-flock__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
/* Hidden until placed: their positions come from the laid-out labels. */
.tabs-flock__dot { position: absolute; left: -1.5px; bottom: 4px; width: 3px; height: 3px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); visibility: hidden; }
.tabs-flock__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function FlockTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const dots = useRef<(HTMLSpanElement | null)[]>([]);
const at = useRef<{ x: number; y: number }[]>([]);
const raf = useRef(0);
const home = (i: number, j: number) => {
const t = tabs.current[i]!;
return t.offsetLeft + t.offsetWidth / 2 + (j - (DOTS - 1) / 2) * SPACE;
};
const draw = () =>
at.current.forEach((p, j) => {
const d = dots.current[j]!;
d.style.transform = `translate(${p.x}px, ${p.y}px)`;
d.style.visibility = 'visible';
});
// Place the flock under the first tab, and again once the font has loaded and the labels have their real widths.
useLayoutEffect(() => {
const place = () => {
at.current = Array.from({ length: DOTS }, (_, j) => ({ x: home(sel, j), y: 0 }));
draw();
};
place();
document.fonts?.ready.then(() => !raf.current && place());
}, []);
const fly = (to: number) => {
cancelAnimationFrame(raf.current);
const from = at.current.map((p) => ({ ...p }));
const dests = Array.from({ length: DOTS }, (_, j) => home(to, j));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
at.current = dests.map((x) => ({ x, y: 0 }));
draw();
return;
}
// The dot nearest the destination leaves first and the rest follow it, a beat apart.
const right = dests[0] > from[0].x;
const start = performance.now();
const step = (now: number) => {
let done = true;
at.current = from.map((p, j) => {
const order = right ? DOTS - 1 - j : j;
const t = Math.min(1, Math.max(0, (now - start - order * 45) / 560));
if (t < 1) done = false;
const e = ease(t);
return { x: p.x + (dests[j] - p.x) * e, y: p.y * (1 - e) - LIFT[j] * Math.sin(Math.PI * t) };
});
draw();
raf.current = done ? 0 : requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
useEffect(() => () => cancelAnimationFrame(raf.current), []);
const pick = (i: number) => {
if (i === sel) return;
setSel(i);
fly(i);
};
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();
pick(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-flock">
<div className="tabs-flock__list" role="tablist" aria-label="Sales period" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-flock__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => pick(i)}
>
{t.label}
</button>
))}
{Array.from({ length: DOTS }, (_, j) => (
<span
key={j}
ref={(el) => {
dots.current[j] = el;
}}
className="tabs-flock__dot"
aria-hidden="true"
/>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-flock__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 initTabsFlock(el) on the mounted element instead.
<div class="tabs-flock">
<div class="tabs-flock__list" role="tablist" aria-label="Sales period">
<button class="tabs-flock__tab" type="button" role="tab" id="tabs-flock-tab-0" aria-selected="false" aria-controls="tabs-flock-panel-0" tabindex="-1">Today</button>
<button class="tabs-flock__tab" type="button" role="tab" id="tabs-flock-tab-1" aria-selected="true" aria-controls="tabs-flock-panel-1" tabindex="0">Week</button>
<button class="tabs-flock__tab" type="button" role="tab" id="tabs-flock-tab-2" aria-selected="false" aria-controls="tabs-flock-panel-2" tabindex="-1">Month</button>
<button class="tabs-flock__tab" type="button" role="tab" id="tabs-flock-tab-3" aria-selected="false" aria-controls="tabs-flock-panel-3" tabindex="-1">Year</button>
<span class="tabs-flock__dot" aria-hidden="true"></span>
<span class="tabs-flock__dot" aria-hidden="true"></span>
<span class="tabs-flock__dot" aria-hidden="true"></span>
<span class="tabs-flock__dot" aria-hidden="true"></span>
<span class="tabs-flock__dot" aria-hidden="true"></span>
<span class="tabs-flock__dot" aria-hidden="true"></span>
</div>
<div class="tabs-flock__panel" role="tabpanel" id="tabs-flock-panel-0" aria-labelledby="tabs-flock-tab-0" hidden="">Two deliveries arriving before noon and one to send.</div>
<div class="tabs-flock__panel" role="tabpanel" id="tabs-flock-panel-1" aria-labelledby="tabs-flock-tab-1">Fourteen orders this week, three of them still to pack.</div>
<div class="tabs-flock__panel" role="tabpanel" id="tabs-flock-panel-2" aria-labelledby="tabs-flock-tab-2" hidden="">Sales are up 12% on last month, led by the spring range.</div>
<div class="tabs-flock__panel" role="tabpanel" id="tabs-flock-panel-3" aria-labelledby="tabs-flock-tab-3" hidden="">The best year yet: 1,840 orders from 31 countries.</div>
</div>.tabs-flock { width: 270px; }
.tabs-flock__list { position: relative; display: flex; gap: 22px; padding-bottom: 12px; }
.tabs-flock__tab { border: 0; padding: 6px 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-flock__tab:hover, .tabs-flock__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-flock__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
/* Hidden until placed: their positions come from the laid-out labels. */
.tabs-flock__dot { position: absolute; left: -1.5px; bottom: 4px; width: 3px; height: 3px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); visibility: hidden; }
.tabs-flock__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsFlock(root) {
var SPACE = 6;
// Every bird takes its own height of arc.
var LIFT = [10, 17, 8, 14, 20, 11];
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var dots = root.querySelectorAll('.tabs-flock__dot');
var DOTS = dots.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var at = [];
var raf = 0;
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function home(i, j) {
var t = tabs[i];
return t.offsetLeft + t.offsetWidth / 2 + (j - (DOTS - 1) / 2) * SPACE;
}
function draw() {
at.forEach(function (p, j) {
dots[j].style.transform = 'translate(' + p.x + 'px, ' + p.y + 'px)';
dots[j].style.visibility = 'visible';
});
}
// Place the flock under the active tab, and again once the font has loaded and the labels have their real widths.
function place() {
if (raf) return;
at = [];
for (var j = 0; j < DOTS; j++) at.push({ x: home(sel, j), y: 0 });
draw();
}
function fly(to) {
cancelAnimationFrame(raf);
var from = at.map(function (p) { return { x: p.x, y: p.y }; });
var dests = from.map(function (p, j) { return home(to, j); });
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
raf = 0;
at = dests.map(function (x) { return { x: x, y: 0 }; });
return draw();
}
// The dot nearest the destination leaves first and the rest follow it, a beat apart.
var right = dests[0] > from[0].x;
var start = performance.now();
function step(now) {
var done = true;
at = from.map(function (p, j) {
var order = right ? DOTS - 1 - j : j;
var t = Math.min(1, Math.max(0, (now - start - order * 45) / 560));
if (t < 1) done = false;
var e = ease(t);
return { x: p.x + (dests[j] - p.x) * e, y: p.y * (1 - e) - LIFT[j % LIFT.length] * Math.sin(Math.PI * t) };
});
draw();
raf = done ? 0 : requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function select(i) {
if (i === sel) return;
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; });
fly(i);
}
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);
place();
if (document.fonts) document.fonts.ready.then(place);
return function () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-flock').forEach(initTabsFlock);Goal: Create a "Flock" tab set.
Visual behavior: Sales tabs with a dotted underline that flies to the new tab like a flock of birds: each dot takes its own arc, a beat after the last, and they line up again on landing.
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.
- Six dots are positioned with transforms from the active tab's measured centre. On a switch, a requestAnimationFrame loop eases each dot to its new place with its own start delay (the dot nearest the destination leaves first) and lifts it by a sine arc of its own height.
- A switch mid-flight starts from where the dots are, so they turn in the air rather than jumping.
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 dots move 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 "Flock" tab set.
Visual behavior: Sales tabs with a dotted underline that flies to the new tab like a flock of birds: each dot takes its own arc, a beat after the last, and they line up again on landing.
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.
- Six dots are positioned with transforms from the active tab's measured centre. On a switch, a requestAnimationFrame loop eases each dot to its new place with its own start delay (the dot nearest the destination leaves first) and lifts it by a sine arc of its own height.
- A switch mid-flight starts from where the dots are, so they turn in the air rather than jumping.
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 dots move 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
Sales tabs with a dotted underline that flies to the new tab like a flock of birds: each dot takes its own arc, a beat after the last, and they line up again on landing.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, delay stagger
- Techniques
- raf, stagger, transform only
- 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 dots move straight to the new tab.