Trace
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Photos', body: 'Every shot from the trip, sorted by the day it was taken.' },
{ label: 'Albums', body: 'Seven albums, including the ones your friends shared.' },
{ label: 'People', body: 'Faces we found in your library, grouped and named.' },
];
const css = `
.tabs-trace { width: 260px; }
.tabs-trace__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.tabs-trace__tab { position: relative; padding: 9px 0; border: 0; border-radius: 9px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.25s; }
.tabs-trace__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-trace__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-trace__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.tabs-trace__line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.tabs-trace__line rect { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-dasharray: 100; stroke-dashoffset: -100; transition: stroke-dashoffset 0.45s cubic-bezier(0.5, 0, 0.3, 1); }
.tabs-trace__line .tabs-trace__ghost { stroke: light-dark(#ebedf0, #232529); stroke-dashoffset: 0; transition: none; }
.tabs-trace__tab[aria-selected='true'] .tabs-trace__line rect { stroke-dashoffset: 0; transition-duration: 0.6s; transition-delay: 0.1s; }
.tabs-trace__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-trace__line rect { transition: none !important; }
}
`;
export function TraceTabs() {
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();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-trace">
<div className="tabs-trace__list" role="tablist" aria-label="Library" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-trace__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
<svg className="tabs-trace__line" aria-hidden="true">
<rect className="tabs-trace__ghost" width="100%" height="100%" rx="9" pathLength={100} />
<rect width="100%" height="100%" rx="9" pathLength={100} />
</svg>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-trace__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 initTabsTrace(el) on the mounted element instead.
<div class="tabs-trace">
<div class="tabs-trace__list" role="tablist" aria-label="Library">
<button class="tabs-trace__tab" type="button" role="tab" id="tabs-trace-tab-0" aria-selected="true" aria-controls="tabs-trace-panel-0" tabindex="0"><svg class="tabs-trace__line" aria-hidden="true"><rect class="tabs-trace__ghost" width="100%" height="100%" rx="9" pathLength="100"></rect><rect width="100%" height="100%" rx="9" pathLength="100"></rect></svg>Photos</button>
<button class="tabs-trace__tab" type="button" role="tab" id="tabs-trace-tab-1" aria-selected="false" aria-controls="tabs-trace-panel-1" tabindex="-1"><svg class="tabs-trace__line" aria-hidden="true"><rect class="tabs-trace__ghost" width="100%" height="100%" rx="9" pathLength="100"></rect><rect width="100%" height="100%" rx="9" pathLength="100"></rect></svg>Albums</button>
<button class="tabs-trace__tab" type="button" role="tab" id="tabs-trace-tab-2" aria-selected="false" aria-controls="tabs-trace-panel-2" tabindex="-1"><svg class="tabs-trace__line" aria-hidden="true"><rect class="tabs-trace__ghost" width="100%" height="100%" rx="9" pathLength="100"></rect><rect width="100%" height="100%" rx="9" pathLength="100"></rect></svg>People</button>
</div>
<div class="tabs-trace__panel" role="tabpanel" id="tabs-trace-panel-0" aria-labelledby="tabs-trace-tab-0">Every shot from the trip, sorted by the day it was taken.</div>
<div class="tabs-trace__panel" role="tabpanel" id="tabs-trace-panel-1" aria-labelledby="tabs-trace-tab-1" hidden="">Seven albums, including the ones your friends shared.</div>
<div class="tabs-trace__panel" role="tabpanel" id="tabs-trace-panel-2" aria-labelledby="tabs-trace-tab-2" hidden="">Faces we found in your library, grouped and named.</div>
</div>.tabs-trace { width: 260px; }
.tabs-trace__list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.tabs-trace__tab { position: relative; padding: 9px 0; border: 0; border-radius: 9px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.25s; }
.tabs-trace__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-trace__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-trace__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.tabs-trace__line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.tabs-trace__line rect { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-dasharray: 100; stroke-dashoffset: -100; transition: stroke-dashoffset 0.45s cubic-bezier(0.5, 0, 0.3, 1); }
.tabs-trace__line .tabs-trace__ghost { stroke: light-dark(#ebedf0, #232529); stroke-dashoffset: 0; transition: none; }
.tabs-trace__tab[aria-selected='true'] .tabs-trace__line rect { stroke-dashoffset: 0; transition-duration: 0.6s; transition-delay: 0.1s; }
.tabs-trace__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-trace__line rect { transition: none !important; }
}function initTabsTrace(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();
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-trace').forEach(initTabsTrace);Goal: Create a "Trace" tab set.
Visual behavior: Photo library tabs where a line draws itself around the active tab and unwinds 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.
- Each tab holds an SVG rounded rectangle the size of the tab (pathLength 100); its dash offset transitions to 0 when the tab is selected and back to 100 when it is not, with a faint ghost outline underneath.
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 outline appears without drawing.
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 "Trace" tab set.
Visual behavior: Photo library tabs where a line draws itself around the active tab and unwinds 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.
- Each tab holds an SVG rounded rectangle the size of the tab (pathLength 100); its dash offset transitions to 0 when the tab is selected and back to 100 when it is not, with a faint ghost outline underneath.
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 outline appears without drawing.
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
Photo library tabs where a line draws itself around the active tab and unwinds from the one you left.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, draw
- Techniques
- stroke dashoffset, svg, 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 outline appears without drawing.