Chapter one
A winter studio, a borrowed press and a stack of offcuts.
Heavyweight cotton, garment dyed and cut slightly boxy.
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Details', body: 'Heavyweight cotton, garment dyed and cut slightly boxy.' },
{ label: 'Shipping', body: 'Free over $80. Ships in two days from Lisbon.' },
{ label: 'Returns', body: 'Thirty days, unworn, with the tags still on.' },
];
const css = `
.tabs-caret { width: 260px; }
.tabs-caret__list { display: grid; grid-template-columns: repeat(3, 1fr); }
.tabs-caret__tab { padding: 8px 0 12px; 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-caret__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-caret__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-caret__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-caret__card {
position: relative;
padding: 14px 16px;
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: 0 0 0 1px light-dark(#ebedf0, #232529), 0 6px 16px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.3));
}
/* The pointer is a turned square in the card's colours, sliding along its top edge. */
.tabs-caret__pointer {
position: absolute;
top: -6px;
left: calc((var(--tabs-caret-i) + 0.5) * 100% / 3 - 6px);
width: 12px;
height: 12px;
border-radius: 2px 0 0 0;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: -1px -1px 0 0 light-dark(#ebedf0, #232529);
transform: rotate(45deg);
transition: left 0.5s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.tabs-caret__panel { min-height: calc(3.1em); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
.tabs-caret__panel > p { margin: 0; animation: tabs-caret-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes tabs-caret-in {
from { opacity: 0; transform: translateX(calc(var(--tabs-caret-dir) * 14px)); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-caret__pointer { transition: none; }
.tabs-caret__panel > p { animation: none; }
}
`;
export function CaretTabs() {
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-caret" style={{ '--tabs-caret-i': sel, '--tabs-caret-dir': dir } as React.CSSProperties}>
<div className="tabs-caret__list" role="tablist" aria-label="Product" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-caret__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>
))}
</div>
<div className="tabs-caret__card">
<span className="tabs-caret__pointer" aria-hidden="true" />
{TABS.map((t, i) => (
<div key={t.label} className="tabs-caret__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{/* Comes in from the side you moved towards. */}
{sel === i && <p>{t.body}</p>}
</div>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsCaret(el) on the mounted element instead.
<div class="tabs-caret" style="--tabs-caret-i:0;--tabs-caret-dir:1">
<div class="tabs-caret__list" role="tablist" aria-label="Product">
<button class="tabs-caret__tab" type="button" role="tab" id="tabs-caret-tab-0" aria-selected="true" aria-controls="tabs-caret-panel-0" tabindex="0">Details</button>
<button class="tabs-caret__tab" type="button" role="tab" id="tabs-caret-tab-1" aria-selected="false" aria-controls="tabs-caret-panel-1" tabindex="-1">Shipping</button>
<button class="tabs-caret__tab" type="button" role="tab" id="tabs-caret-tab-2" aria-selected="false" aria-controls="tabs-caret-panel-2" tabindex="-1">Returns</button>
</div>
<div class="tabs-caret__card">
<span class="tabs-caret__pointer" aria-hidden="true"></span>
<div class="tabs-caret__panel" role="tabpanel" id="tabs-caret-panel-0" aria-labelledby="tabs-caret-tab-0">
<p>Heavyweight cotton, garment dyed and cut slightly boxy.</p>
</div>
<div class="tabs-caret__panel" role="tabpanel" id="tabs-caret-panel-1" aria-labelledby="tabs-caret-tab-1" hidden="">
<p>Free over $80. Ships in two days from Lisbon.</p>
</div>
<div class="tabs-caret__panel" role="tabpanel" id="tabs-caret-panel-2" aria-labelledby="tabs-caret-tab-2" hidden="">
<p>Thirty days, unworn, with the tags still on.</p>
</div>
</div>
</div>.tabs-caret { width: 260px; }
.tabs-caret__list { display: grid; grid-template-columns: repeat(3, 1fr); }
.tabs-caret__tab { padding: 8px 0 12px; 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-caret__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-caret__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-caret__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-caret__card {
position: relative;
padding: 14px 16px;
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: 0 0 0 1px light-dark(#ebedf0, #232529), 0 6px 16px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.3));
}
/* The pointer is a turned square in the card's colours, sliding along its top edge. */
.tabs-caret__pointer {
position: absolute;
top: -6px;
left: calc((var(--tabs-caret-i) + 0.5) * 100% / 3 - 6px);
width: 12px;
height: 12px;
border-radius: 2px 0 0 0;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: -1px -1px 0 0 light-dark(#ebedf0, #232529);
transform: rotate(45deg);
transition: left 0.5s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.tabs-caret__panel { min-height: calc(3.1em); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
.tabs-caret__panel > p { margin: 0; animation: tabs-caret-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes tabs-caret-in {
from { opacity: 0; transform: translateX(calc(var(--tabs-caret-dir) * 14px)); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-caret__pointer { transition: none; }
.tabs-caret__panel > p { animation: none; }
}function initTabsCaret(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 pointer slides under the new tab; the text comes in from the side you moved towards.
root.style.setProperty('--tabs-caret-dir', Math.sign(i - sel));
root.style.setProperty('--tabs-caret-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-caret').forEach(initTabsCaret);Goal: Create a "Caret" tab set.
Visual behavior: Product tabs over a card whose pointer slides along its top edge to sit under the active tab, while the new text slides in from the side you moved towards.
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 pointer is a small square turned 45 degrees in the card's colours and outlined on its upper edges only, positioned from the selected index on a springy transition.
- The direction of the move is a CSS variable, so the panel's entrance keyframes slide in from the matching side; showing a panel replays 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 pointer and the text change at once.
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 "Caret" tab set.
Visual behavior: Product tabs over a card whose pointer slides along its top edge to sit under the active tab, while the new text slides in from the side you moved towards.
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 pointer is a small square turned 45 degrees in the card's colours and outlined on its upper edges only, positioned from the selected index on a springy transition.
- The direction of the move is a CSS variable, so the panel's entrance keyframes slide in from the matching side; showing a panel replays 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 pointer and the text change at once.
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
Product tabs over a card whose pointer slides along its top edge to sit under the active tab, while the new text slides in from the side you moved towards.
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 pointer and the text change at once.
Chapter one
A winter studio, a borrowed press and a stack of offcuts.