Inchworm
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Overview', body: 'Revenue is up 12% on last month, led by annual plans.' },
{ label: 'Activity', body: 'Three people joined the team and two invoices were paid today.' },
{ label: 'Settings', body: 'Billing, team access and when we send you notifications.' },
];
const css = `
.tabs-inchworm { width: 260px; }
.tabs-inchworm__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); box-shadow: inset 0 -1px 0 light-dark(#ebedf0, #232529); }
.tabs-inchworm__tab { padding: 10px 0 13px; border: 0; border-radius: 6px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-inchworm__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-inchworm__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-inchworm__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-inchworm__worm {
position: absolute;
bottom: 0;
height: 2px;
border-radius: 1px;
background: light-dark(#373a40, #f1f2f3);
left: calc(var(--tabs-inchworm-i) / 3 * 100% + 16px);
right: calc((2 - var(--tabs-inchworm-i)) / 3 * 100% + 16px);
transition:
left 0.32s cubic-bezier(0.6, 0, 0.2, 1) var(--tabs-inchworm-tail-l),
right 0.32s cubic-bezier(0.6, 0, 0.2, 1) var(--tabs-inchworm-tail-r);
}
.tabs-inchworm__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-inchworm__worm { transition: none; }
}
`;
export function InchwormTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [dir, setDir] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number) => {
if (i === sel) return;
setDir(Math.sign(i - sel));
setSel(i);
};
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();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div
className="tabs-inchworm"
style={
{
'--tabs-inchworm-i': sel,
// The edge facing the new tab sets off first; the tail follows a beat later.
'--tabs-inchworm-tail-l': dir > 0 ? '0.14s' : '0s',
'--tabs-inchworm-tail-r': dir > 0 ? '0s' : '0.14s',
} as React.CSSProperties
}
>
<div className="tabs-inchworm__list" role="tablist" aria-label="Dashboard" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-inchworm__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-inchworm__worm" aria-hidden="true" />
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-inchworm__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 initTabsInchworm(el) on the mounted element instead.
<div class="tabs-inchworm" style="--tabs-inchworm-i:0;--tabs-inchworm-tail-l:0.14s;--tabs-inchworm-tail-r:0s">
<div class="tabs-inchworm__list" role="tablist" aria-label="Dashboard">
<button class="tabs-inchworm__tab" type="button" role="tab" id="tabs-inchworm-tab-0" aria-selected="true" aria-controls="tabs-inchworm-panel-0" tabindex="0">Overview</button>
<button class="tabs-inchworm__tab" type="button" role="tab" id="tabs-inchworm-tab-1" aria-selected="false" aria-controls="tabs-inchworm-panel-1" tabindex="-1">Activity</button>
<button class="tabs-inchworm__tab" type="button" role="tab" id="tabs-inchworm-tab-2" aria-selected="false" aria-controls="tabs-inchworm-panel-2" tabindex="-1">Settings</button>
<span class="tabs-inchworm__worm" aria-hidden="true"></span>
</div>
<div class="tabs-inchworm__panel" role="tabpanel" id="tabs-inchworm-panel-0" aria-labelledby="tabs-inchworm-tab-0">Revenue is up 12% on last month, led by annual plans.</div>
<div class="tabs-inchworm__panel" role="tabpanel" id="tabs-inchworm-panel-1" aria-labelledby="tabs-inchworm-tab-1" hidden="">Three people joined the team and two invoices were paid today.</div>
<div class="tabs-inchworm__panel" role="tabpanel" id="tabs-inchworm-panel-2" aria-labelledby="tabs-inchworm-tab-2" hidden="">Billing, team access and when we send you notifications.</div>
</div>.tabs-inchworm { width: 260px; }
.tabs-inchworm__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); box-shadow: inset 0 -1px 0 light-dark(#ebedf0, #232529); }
.tabs-inchworm__tab { padding: 10px 0 13px; border: 0; border-radius: 6px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-inchworm__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-inchworm__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-inchworm__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-inchworm__worm {
position: absolute;
bottom: 0;
height: 2px;
border-radius: 1px;
background: light-dark(#373a40, #f1f2f3);
left: calc(var(--tabs-inchworm-i) / 3 * 100% + 16px);
right: calc((2 - var(--tabs-inchworm-i)) / 3 * 100% + 16px);
transition:
left 0.32s cubic-bezier(0.6, 0, 0.2, 1) var(--tabs-inchworm-tail-l),
right 0.32s cubic-bezier(0.6, 0, 0.2, 1) var(--tabs-inchworm-tail-r);
}
.tabs-inchworm__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-inchworm__worm { transition: none; }
}function initTabsInchworm(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;
// The edge facing the new tab sets off first; the tail follows a beat later.
var dir = Math.sign(i - sel);
root.style.setProperty('--tabs-inchworm-tail-l', dir > 0 ? '0.14s' : '0s');
root.style.setProperty('--tabs-inchworm-tail-r', dir > 0 ? '0s' : '0.14s');
root.style.setProperty('--tabs-inchworm-i', i);
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();
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-inchworm').forEach(initTabsInchworm);Goal: Create a "Inchworm" tab set.
Visual behavior: Dashboard tabs with an underline that moves like an inchworm: its leading edge reaches the new tab first and the tail draws up behind it.
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 underline is positioned by left and right, both from the selected index. Each change gives the edge facing the new tab no delay and the trailing edge a short one, so the line stretches out, then contracts.
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 underline jumps 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 "Inchworm" tab set.
Visual behavior: Dashboard tabs with an underline that moves like an inchworm: its leading edge reaches the new tab first and the tail draws up behind it.
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 underline is positioned by left and right, both from the selected index. Each change gives the edge facing the new tab no delay and the trailing edge a short one, so the line stretches out, then contracts.
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 underline jumps 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
Dashboard tabs with an underline that moves like an inchworm: its leading edge reaches the new tab first and the tail draws up behind it.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change
- Techniques
- 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 underline jumps to the new tab.