Wash
import { useId, useRef, useState } from 'react';
const ITEMS = [
{ label: 'Dawn', body: 'Soft greys and a cool, quiet light.' },
{ label: 'Noon', body: 'Hard edges, bright paper, short shadows.' },
{ label: 'Dusk', body: 'Long shadows and a warm, low glow.' },
];
const css = `
@keyframes tabs-wash-bloom-a { 0% { transform: scale(0.3, 0.5); opacity: 0; } 60% { opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes tabs-wash-bloom-b { 0% { transform: scale(0.3, 0.5); opacity: 0; } 60% { opacity: 1; } 100% { transform: none; opacity: 1; } }
.tabs-wash { display: grid; gap: 14px; width: 234px; }
.tabs-wash__list { position: relative; display: flex; }
.tabs-wash__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-wash__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
.tabs-wash__tab { position: relative; z-index: 1; width: 78px; height: 44px; color: light-dark(#5f626a, #a7abb2); font-size: 14px; font-weight: 500; transition: color 0.3s; }
.tabs-wash__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
/* The wash: a soft blot whose edge is roughened by noise, so it reads as paint soaked into paper. */
.tabs-wash__wash { position: absolute; top: 2px; left: calc(var(--i) * 78px + 2px); width: 74px; height: 40px; pointer-events: none; transition: left 0.5s cubic-bezier(0.5, 0, 0.3, 1); }
.tabs-wash__wash svg { width: 100%; height: 100%; overflow: visible; }
.tabs-wash__wash ellipse { fill: light-dark(#42454c, #e5e6e9); }
.tabs-wash__wash .tabs-wash__edge { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; opacity: 0.6; }
.tabs-wash[data-flip='a'] .tabs-wash__wash svg { animation: tabs-wash-bloom-a 0.7s cubic-bezier(0.2, 0.8, 0.3, 1); }
.tabs-wash[data-flip='b'] .tabs-wash__wash svg { animation: tabs-wash-bloom-b 0.7s cubic-bezier(0.2, 0.8, 0.3, 1); }
.tabs-wash__panel { color: light-dark(#50535b, #c6c8cd); font-size: 13px; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-wash__wash { transition: none; }
.tabs-wash__wash svg { animation: none !important; }
}
`;
export function WashTabs() {
const id = useId();
const [state, setState] = useState({ sel: 0, prev: -1, flip: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const { sel, prev, flip } = state;
const select = (i: number) => {
if (i !== sel) setState((s) => ({ sel: i, prev: s.sel, flip: s.flip + 1 }));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = ITEMS.length;
const next = ({ ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 } as Record<string, number>)[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
const filter = `tabs-wash-${id.replace(/[^a-zA-Z0-9]/g, '')}`;
return (
<>
<style>
{css}
</style>
<div
className="tabs-wash"
data-armed={flip ? '' : undefined}
data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}
style={{ '--i': sel, '--p': prev < 0 ? sel : prev } as React.CSSProperties}
>
<div className="tabs-wash__list" role="tablist" aria-label="Palette" onKeyDown={onKeyDown}>
{ITEMS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-wash__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)}
>
{t.label}
</button>
))}
<span className="tabs-wash__wash" aria-hidden="true">
<svg viewBox="0 0 74 40">
<defs>
<filter id={filter} x="-20%" y="-30%" width="140%" height="160%">
<feTurbulence type="fractalNoise" baseFrequency="0.06" numOctaves="3" seed="7" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="9" />
</filter>
</defs>
<g filter={`url(#${filter})`}>
<ellipse cx="37" cy="20" rx="33" ry="15" />
<ellipse className="tabs-wash__edge" cx="37" cy="20" rx="33" ry="15" />
</g>
</svg>
</span>
</div>
{ITEMS.map((t, i) => (
<div key={t.label} className="tabs-wash__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 initTabsWash(el) on the mounted element instead.
<div class="tabs-wash" style="--i:0;--p:0">
<div class="tabs-wash__list" role="tablist" aria-label="Palette">
<button class="tabs-wash__tab" type="button" role="tab" id="tabs-wash-tab-0" aria-selected="true" aria-controls="tabs-wash-panel-0" tabindex="0">Dawn</button>
<button class="tabs-wash__tab" type="button" role="tab" id="tabs-wash-tab-1" aria-selected="false" aria-controls="tabs-wash-panel-1" tabindex="-1">Noon</button>
<button class="tabs-wash__tab" type="button" role="tab" id="tabs-wash-tab-2" aria-selected="false" aria-controls="tabs-wash-panel-2" tabindex="-1">Dusk</button>
<span class="tabs-wash__wash" aria-hidden="true">
<svg viewBox="0 0 74 40">
<defs>
<filter id="tabs-wash-def" x="-20%" y="-30%" width="140%" height="160%">
<feTurbulence type="fractalNoise" baseFrequency="0.06" numOctaves="3" seed="7" result="n"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="9"></feDisplacementMap>
</filter>
</defs>
<g filter="url(#tabs-wash-def)">
<ellipse cx="37" cy="20" rx="33" ry="15"></ellipse>
<ellipse class="tabs-wash__edge" cx="37" cy="20" rx="33" ry="15"></ellipse>
</g>
</svg>
</span>
</div>
<div class="tabs-wash__panel" role="tabpanel" id="tabs-wash-panel-0" aria-labelledby="tabs-wash-tab-0">Soft greys and a cool, quiet light.</div>
<div class="tabs-wash__panel" role="tabpanel" id="tabs-wash-panel-1" aria-labelledby="tabs-wash-tab-1" hidden="">Hard edges, bright paper, short shadows.</div>
<div class="tabs-wash__panel" role="tabpanel" id="tabs-wash-panel-2" aria-labelledby="tabs-wash-tab-2" hidden="">Long shadows and a warm, low glow.</div>
</div>@keyframes tabs-wash-bloom-a { 0% { transform: scale(0.3, 0.5); opacity: 0; } 60% { opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes tabs-wash-bloom-b { 0% { transform: scale(0.3, 0.5); opacity: 0; } 60% { opacity: 1; } 100% { transform: none; opacity: 1; } }
.tabs-wash { display: grid; gap: 14px; width: 234px; }
.tabs-wash__list { position: relative; display: flex; }
.tabs-wash__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-wash__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
.tabs-wash__tab { position: relative; z-index: 1; width: 78px; height: 44px; color: light-dark(#5f626a, #a7abb2); font-size: 14px; font-weight: 500; transition: color 0.3s; }
.tabs-wash__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
/* The wash: a soft blot whose edge is roughened by noise, so it reads as paint soaked into paper. */
.tabs-wash__wash { position: absolute; top: 2px; left: calc(var(--i) * 78px + 2px); width: 74px; height: 40px; pointer-events: none; transition: left 0.5s cubic-bezier(0.5, 0, 0.3, 1); }
.tabs-wash__wash svg { width: 100%; height: 100%; overflow: visible; }
.tabs-wash__wash ellipse { fill: light-dark(#42454c, #e5e6e9); }
.tabs-wash__wash .tabs-wash__edge { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; opacity: 0.6; }
.tabs-wash[data-flip='a'] .tabs-wash__wash svg { animation: tabs-wash-bloom-a 0.7s cubic-bezier(0.2, 0.8, 0.3, 1); }
.tabs-wash[data-flip='b'] .tabs-wash__wash svg { animation: tabs-wash-bloom-b 0.7s cubic-bezier(0.2, 0.8, 0.3, 1); }
.tabs-wash__panel { color: light-dark(#50535b, #c6c8cd); font-size: 13px; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-wash__wash { transition: none; }
.tabs-wash__wash svg { animation: none !important; }
}function initTabsWash(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 flip = 0;
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
// Armed on the first change, so nothing animates on load; each change flips data-flip
// between a and b, so the keyframes restart every time.
flip += 1;
root.setAttribute('data-armed', '');
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
root.style.setProperty('--i', i);
root.style.setProperty('--p', prev);
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; });
}
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-wash').forEach(initTabsWash);Goal: Create a "Wash" tab set.
Visual behavior: Palette tabs marked by a watercolour wash: the soft blot glides to the new tab and blooms into the paper, its edge roughened like paint soaking in.
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 selected index is a CSS custom property (--i) on the root, so the moving parts are positioned with calc() and transitions alone.
- The wash is an SVG ellipse under a filter of feTurbulence noise and feDisplacementMap, which gives it a ragged, soaked-in edge, with a stronger copy of the outline for the pooled rim.
- It slides to the tab with a transition and blooms open with a scale keyframe each time.
- Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
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 wash jumps to the tab without blooming.
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 "Wash" tab set.
Visual behavior: Palette tabs marked by a watercolour wash: the soft blot glides to the new tab and blooms into the paper, its edge roughened like paint soaking in.
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 selected index is a CSS custom property (--i) on the root, so the moving parts are positioned with calc() and transitions alone.
- The wash is an SVG ellipse under a filter of feTurbulence noise and feDisplacementMap, which gives it a ragged, soaked-in edge, with a stronger copy of the outline for the pooled rim.
- It slides to the tab with a transition and blooms open with a scale keyframe each time.
- Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
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 wash jumps to the tab without blooming.
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
Palette tabs marked by a watercolour wash: the soft blot glides to the new tab and blooms into the paper, its edge roughened like paint soaking in.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, fill
- Techniques
- svg filter, keyframes, css variables, css transition
- 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 wash jumps to the tab without blooming.