Ripple
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'All', body: 'Everything in the library, newest first: 248 sounds.' },
{ label: 'Ambient', body: 'Rooms, weather and city air, for under everything else.' },
{ label: 'Foley', body: 'Footsteps, cloth and paper, recorded close.' },
{ label: 'Interface', body: 'Clicks, ticks and soft confirmations for product work.' },
];
const css = `
.tabs-ripple { position: relative; width: 280px; }
.tabs-ripple__list { display: flex; gap: 18px; }
.tabs-ripple__tab { position: relative; border: 0; padding: 8px 0; background: none; font: inherit; font-size: 14px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-ripple__tab:hover, .tabs-ripple__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ripple__tab::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 1px;
height: 1.5px;
border-radius: 1px;
background: currentColor;
transform: scaleX(0);
transition: transform 0.4s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.tabs-ripple__tab[aria-selected='true']::after { transform: scaleX(1); }
.tabs-ripple__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-ripple__letter { display: inline-block; white-space: pre; }
.tabs-ripple__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tabs-ripple__panel { padding: 14px 0 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-ripple__tab::after { transition: none; }
}
`;
export function RippleTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const root = useRef<HTMLDivElement>(null);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
// Every letter bobs once, later and smaller the further it sits from where the ripple started.
const ripple = (x: number) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const box = root.current!.getBoundingClientRect();
root.current!.querySelectorAll<HTMLElement>('.tabs-ripple__letter').forEach((l) => {
const r = l.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - box.left - x);
const a = 7 * Math.exp(-d / 170);
if (a < 0.5) return;
l.animate([{ transform: 'none' }, { transform: `translateY(${-a}px)`, offset: 0.3 }, { transform: `translateY(${a * 0.28}px)`, offset: 0.65 }, { transform: 'none' }], {
duration: 620,
delay: d / 0.42,
easing: 'ease-in-out',
});
});
};
const pick = (i: number, x?: number) => {
setSel(i);
const t = tabs.current[i]!;
ripple(x ?? t.offsetLeft + t.offsetWidth / 2);
};
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-ripple" ref={root}>
<div className="tabs-ripple__list" role="tablist" aria-label="Sound library" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-ripple__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
// A pointer starts the ripple where it pressed; Enter and Space start it at the tab's centre.
onClick={(e) => pick(i, e.detail ? e.clientX - root.current!.getBoundingClientRect().left : undefined)}
>
<span className="tabs-ripple__sr">{t.label}</span>
<span aria-hidden="true">
{[...t.label].map((c, k) => (
<span key={k} className="tabs-ripple__letter">
{c}
</span>
))}
</span>
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-ripple__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 initTabsRipple(el) on the mounted element instead.
<div class="tabs-ripple">
<div class="tabs-ripple__list" role="tablist" aria-label="Sound library">
<button class="tabs-ripple__tab" type="button" role="tab" id="tabs-ripple-tab-0" aria-selected="true" aria-controls="tabs-ripple-panel-0" tabindex="0">
<span class="tabs-ripple__sr">All</span>
<span aria-hidden="true">
<span class="tabs-ripple__letter">A</span>
<span class="tabs-ripple__letter">l</span>
<span class="tabs-ripple__letter">l</span>
</span>
</button>
<button class="tabs-ripple__tab" type="button" role="tab" id="tabs-ripple-tab-1" aria-selected="false" aria-controls="tabs-ripple-panel-1" tabindex="-1">
<span class="tabs-ripple__sr">Ambient</span>
<span aria-hidden="true">
<span class="tabs-ripple__letter">A</span>
<span class="tabs-ripple__letter">m</span>
<span class="tabs-ripple__letter">b</span>
<span class="tabs-ripple__letter">i</span>
<span class="tabs-ripple__letter">e</span>
<span class="tabs-ripple__letter">n</span>
<span class="tabs-ripple__letter">t</span>
</span>
</button>
<button class="tabs-ripple__tab" type="button" role="tab" id="tabs-ripple-tab-2" aria-selected="false" aria-controls="tabs-ripple-panel-2" tabindex="-1">
<span class="tabs-ripple__sr">Foley</span>
<span aria-hidden="true">
<span class="tabs-ripple__letter">F</span>
<span class="tabs-ripple__letter">o</span>
<span class="tabs-ripple__letter">l</span>
<span class="tabs-ripple__letter">e</span>
<span class="tabs-ripple__letter">y</span>
</span>
</button>
<button class="tabs-ripple__tab" type="button" role="tab" id="tabs-ripple-tab-3" aria-selected="false" aria-controls="tabs-ripple-panel-3" tabindex="-1">
<span class="tabs-ripple__sr">Interface</span>
<span aria-hidden="true">
<span class="tabs-ripple__letter">I</span>
<span class="tabs-ripple__letter">n</span>
<span class="tabs-ripple__letter">t</span>
<span class="tabs-ripple__letter">e</span>
<span class="tabs-ripple__letter">r</span>
<span class="tabs-ripple__letter">f</span>
<span class="tabs-ripple__letter">a</span>
<span class="tabs-ripple__letter">c</span>
<span class="tabs-ripple__letter">e</span>
</span>
</button>
</div>
<div class="tabs-ripple__panel" role="tabpanel" id="tabs-ripple-panel-0" aria-labelledby="tabs-ripple-tab-0">Everything in the library, newest first: 248 sounds.</div>
<div class="tabs-ripple__panel" role="tabpanel" id="tabs-ripple-panel-1" aria-labelledby="tabs-ripple-tab-1" hidden="">Rooms, weather and city air, for under everything else.</div>
<div class="tabs-ripple__panel" role="tabpanel" id="tabs-ripple-panel-2" aria-labelledby="tabs-ripple-tab-2" hidden="">Footsteps, cloth and paper, recorded close.</div>
<div class="tabs-ripple__panel" role="tabpanel" id="tabs-ripple-panel-3" aria-labelledby="tabs-ripple-tab-3" hidden="">Clicks, ticks and soft confirmations for product work.</div>
</div>.tabs-ripple { position: relative; width: 280px; }
.tabs-ripple__list { display: flex; gap: 18px; }
.tabs-ripple__tab { position: relative; border: 0; padding: 8px 0; background: none; font: inherit; font-size: 14px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-ripple__tab:hover, .tabs-ripple__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-ripple__tab::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 1px;
height: 1.5px;
border-radius: 1px;
background: currentColor;
transform: scaleX(0);
transition: transform 0.4s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.tabs-ripple__tab[aria-selected='true']::after { transform: scaleX(1); }
.tabs-ripple__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-ripple__letter { display: inline-block; white-space: pre; }
.tabs-ripple__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tabs-ripple__panel { padding: 14px 0 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-ripple__tab::after { transition: none; }
}function initTabsRipple(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'; });
// Every letter bobs once, later and smaller the further it sits from where the ripple started.
function ripple(x) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var box = root.getBoundingClientRect();
root.querySelectorAll('.tabs-ripple__letter').forEach(function (l) {
var r = l.getBoundingClientRect();
var d = Math.abs(r.left + r.width / 2 - box.left - x);
var a = 7 * Math.exp(-d / 170);
if (a < 0.5) return;
l.animate(
[
{ transform: 'none' },
{ transform: 'translateY(' + -a + 'px)', offset: 0.3 },
{ transform: 'translateY(' + a * 0.28 + 'px)', offset: 0.65 },
{ transform: 'none' },
],
{ duration: 620, delay: d / 0.42, easing: 'ease-in-out' }
);
});
}
function select(i, x) {
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; });
ripple(x === undefined ? tabs[i].offsetLeft + tabs[i].offsetWidth / 2 : x);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (!tab) return;
// A pointer starts the ripple where it pressed; Enter and Space start it at the tab's centre.
select(tabs.indexOf(tab), e.detail ? e.clientX - root.getBoundingClientRect().left : undefined);
}
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-ripple').forEach(initTabsRipple);Goal: Create a "Ripple" tab set.
Visual behavior: Library tabs where picking one sends a ripple through the letters of every label, starting from where you clicked and fading as it travels.
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.
- Each label is split into letters (hidden from assistive tech, with the whole word kept in a visually hidden span). On a click, every letter gets a short up-and-down animation through the Web Animations API, delayed by its distance from the click and smaller with distance, so the wave spreads out from the pointer.
- Enter and Space start it from the centre of the tab. An underline grows under the active label.
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 there is no ripple.
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 "Ripple" tab set.
Visual behavior: Library tabs where picking one sends a ripple through the letters of every label, starting from where you clicked and fading as it travels.
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.
- Each label is split into letters (hidden from assistive tech, with the whole word kept in a visually hidden span). On a click, every letter gets a short up-and-down animation through the Web Animations API, delayed by its distance from the click and smaller with distance, so the wave spreads out from the pointer.
- Enter and Space start it from the centre of the tab. An underline grows under the active label.
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 there is no ripple.
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
Library tabs where picking one sends a ripple through the letters of every label, starting from where you clicked and fading as it travels.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, delay stagger, mouse tracking
- Techniques
- event position, text animation, stagger
- 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 there is no ripple.