Ink
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Inbox', body: 'Twelve new messages, three of them flagged for today.' },
{ label: 'Sent', body: 'Your last message went out to the design team at 9:14.' },
{ label: 'Drafts', body: 'Two drafts saved, the newest from this morning.' },
];
const css = `
.tabs-ink { width: 260px; }
.tabs-ink__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.tabs-ink__tab {
position: relative;
overflow: hidden;
padding: 9px 0;
border: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#50535b, #c6c8cd);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
isolation: isolate;
transition: color 0.3s;
}
.tabs-ink__tab::before {
content: '';
position: absolute;
left: var(--tabs-ink-x, 50%);
top: var(--tabs-ink-y, 50%);
z-index: -1;
width: 240%;
aspect-ratio: 1;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
transform: translate(-50%, -50%) scale(0);
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.tabs-ink__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
.tabs-ink__tab[aria-selected='true']::before { transform: translate(-50%, -50%) scale(1); }
.tabs-ink__tab:hover { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); }
.tabs-ink__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-ink__panel { padding: 14px 4px 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-ink__tab::before { transition: none; }
}
`;
export function InkTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
tabs.current[next]?.style.removeProperty('--tabs-ink-x');
tabs.current[next]?.style.removeProperty('--tabs-ink-y');
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-ink">
<div className="tabs-ink__list" role="tablist" aria-label="Mail" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-ink__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onPointerDown={(e) => {
// The ink starts where you pressed.
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty('--tabs-ink-x', `${e.clientX - r.left}px`);
e.currentTarget.style.setProperty('--tabs-ink-y', `${e.clientY - r.top}px`);
}}
onClick={() => setSel(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-ink__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 initTabsInk(el) on the mounted element instead.
<div class="tabs-ink">
<div class="tabs-ink__list" role="tablist" aria-label="Mail">
<button class="tabs-ink__tab" type="button" role="tab" id="tabs-ink-tab-0" aria-selected="true" aria-controls="tabs-ink-panel-0" tabindex="0">Inbox</button>
<button class="tabs-ink__tab" type="button" role="tab" id="tabs-ink-tab-1" aria-selected="false" aria-controls="tabs-ink-panel-1" tabindex="-1">Sent</button>
<button class="tabs-ink__tab" type="button" role="tab" id="tabs-ink-tab-2" aria-selected="false" aria-controls="tabs-ink-panel-2" tabindex="-1">Drafts</button>
</div>
<div class="tabs-ink__panel" role="tabpanel" id="tabs-ink-panel-0" aria-labelledby="tabs-ink-tab-0">Twelve new messages, three of them flagged for today.</div>
<div class="tabs-ink__panel" role="tabpanel" id="tabs-ink-panel-1" aria-labelledby="tabs-ink-tab-1" hidden="">Your last message went out to the design team at 9:14.</div>
<div class="tabs-ink__panel" role="tabpanel" id="tabs-ink-panel-2" aria-labelledby="tabs-ink-tab-2" hidden="">Two drafts saved, the newest from this morning.</div>
</div>.tabs-ink { width: 260px; }
.tabs-ink__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.tabs-ink__tab {
position: relative;
overflow: hidden;
padding: 9px 0;
border: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#50535b, #c6c8cd);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
isolation: isolate;
transition: color 0.3s;
}
.tabs-ink__tab::before {
content: '';
position: absolute;
left: var(--tabs-ink-x, 50%);
top: var(--tabs-ink-y, 50%);
z-index: -1;
width: 240%;
aspect-ratio: 1;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
transform: translate(-50%, -50%) scale(0);
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.tabs-ink__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); }
.tabs-ink__tab[aria-selected='true']::before { transform: translate(-50%, -50%) scale(1); }
.tabs-ink__tab:hover { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); }
.tabs-ink__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-ink__panel { padding: 14px 4px 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-ink__tab::before { transition: none; }
}function initTabsInk(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'; });
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; });
}
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();
// From the keyboard the ink starts in the middle of the tab.
tabs[next].style.removeProperty('--tabs-ink-x');
tabs[next].style.removeProperty('--tabs-ink-y');
select(next);
tabs[next].focus();
}
function onPointerDown(e) {
var tab = e.target.closest('[role="tab"]');
if (!tab) return;
// The ink starts where you pressed.
var r = tab.getBoundingClientRect();
tab.style.setProperty('--tabs-ink-x', e.clientX - r.left + 'px');
tab.style.setProperty('--tabs-ink-y', e.clientY - r.top + 'px');
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
list.addEventListener('pointerdown', onPointerDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
list.removeEventListener('pointerdown', onPointerDown);
};
}
document.querySelectorAll('.tabs-ink').forEach(initTabsInk);Goal: Create a "Ink" tab set.
Visual behavior: Mail tabs where ink spreads out from exactly where you pressed the tab and drains away from the one you left.
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.
- On pointer down the press position is written to two custom properties on that tab; a circular ::before scales up from there when the tab is selected and back down when it is not.
- From the keyboard those properties are cleared, so the ink grows from the middle.
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 ink appears and disappears without spreading.
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 "Ink" tab set.
Visual behavior: Mail tabs where ink spreads out from exactly where you pressed the tab and drains away from the one you left.
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.
- On pointer down the press position is written to two custom properties on that tab; a circular ::before scales up from there when the tab is selected and back down when it is not.
- From the keyboard those properties are cleared, so the ink grows from the middle.
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 ink appears and disappears without spreading.
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 where ink spreads out from exactly where you pressed the tab and drains away from the one you left.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, fill
- Techniques
- css variables, css transition, event position
- 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 ink appears and disappears without spreading.