Kern
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Spring', body: 'Washed linens and unlined jackets in chalk and sage.' },
{ label: 'Summer', body: 'Open weaves, raw silk and sandals made in Porto.' },
{ label: 'Autumn', body: 'Brushed wool, corduroy and a long camel coat.' },
];
const TIGHT = '0.06em';
const OPEN = '0.42em';
const EASE = '0.55s cubic-bezier(0.3, 1, 0.35, 1)';
const css = `
.tabs-kern { width: 280px; }
.tabs-kern__list { display: flex; justify-content: center; gap: 22px; }
.tabs-kern__tab { border: 0; padding: 8px 0; background: none; font: inherit; font-size: 11px; font-weight: 600; text-transform: uppercase; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.4s; }
.tabs-kern__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-kern__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-kern__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
/* Letter spacing also follows the last letter, so a negative margin takes that trailing space back. */
.tabs-kern__word { position: relative; display: inline-block; letter-spacing: ${TIGHT}; margin-right: -${TIGHT}; transition: letter-spacing ${EASE}, margin-right ${EASE}; }
.tabs-kern__word::after {
content: '';
position: absolute;
left: 0;
right: ${TIGHT};
bottom: -5px;
height: 1px;
background: currentColor;
transform: scaleX(0);
transition: transform ${EASE}, right ${EASE};
}
.tabs-kern__tab[aria-selected='true'] .tabs-kern__word { letter-spacing: ${OPEN}; margin-right: -${OPEN}; }
.tabs-kern__tab[aria-selected='true'] .tabs-kern__word::after { right: ${OPEN}; transform: scaleX(1); }
.tabs-kern__panel { padding: 16px 8px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-kern__word, .tabs-kern__word::after { transition: none; }
}
`;
export function KernTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
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-kern">
<div className="tabs-kern__list" role="tablist" aria-label="Collections" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-kern__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)}
>
<span className="tabs-kern__word">{t.label}</span>
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-kern__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 initTabsKern(el) on the mounted element instead.
<div class="tabs-kern">
<div class="tabs-kern__list" role="tablist" aria-label="Collections">
<button class="tabs-kern__tab" type="button" role="tab" id="tabs-kern-tab-0" aria-selected="false" aria-controls="tabs-kern-panel-0" tabindex="-1">
<span class="tabs-kern__word">Spring</span>
</button>
<button class="tabs-kern__tab" type="button" role="tab" id="tabs-kern-tab-1" aria-selected="true" aria-controls="tabs-kern-panel-1" tabindex="0">
<span class="tabs-kern__word">Summer</span>
</button>
<button class="tabs-kern__tab" type="button" role="tab" id="tabs-kern-tab-2" aria-selected="false" aria-controls="tabs-kern-panel-2" tabindex="-1">
<span class="tabs-kern__word">Autumn</span>
</button>
</div>
<div class="tabs-kern__panel" role="tabpanel" id="tabs-kern-panel-0" aria-labelledby="tabs-kern-tab-0" hidden="">Washed linens and unlined jackets in chalk and sage.</div>
<div class="tabs-kern__panel" role="tabpanel" id="tabs-kern-panel-1" aria-labelledby="tabs-kern-tab-1">Open weaves, raw silk and sandals made in Porto.</div>
<div class="tabs-kern__panel" role="tabpanel" id="tabs-kern-panel-2" aria-labelledby="tabs-kern-tab-2" hidden="">Brushed wool, corduroy and a long camel coat.</div>
</div>.tabs-kern { width: 280px; }
.tabs-kern__list { display: flex; justify-content: center; gap: 22px; }
.tabs-kern__tab { border: 0; padding: 8px 0; background: none; font: inherit; font-size: 11px; font-weight: 600; text-transform: uppercase; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.4s; }
.tabs-kern__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-kern__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-kern__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
/* Letter spacing also follows the last letter, so a negative margin takes that trailing space back. */
.tabs-kern__word { position: relative; display: inline-block; letter-spacing: 0.06em; margin-right: -0.06em; transition: letter-spacing 0.55s cubic-bezier(0.3, 1, 0.35, 1), margin-right 0.55s cubic-bezier(0.3, 1, 0.35, 1); }
.tabs-kern__word::after {
content: '';
position: absolute;
left: 0;
right: 0.06em;
bottom: -5px;
height: 1px;
background: currentColor;
transform: scaleX(0);
transition: transform 0.55s cubic-bezier(0.3, 1, 0.35, 1), right 0.55s cubic-bezier(0.3, 1, 0.35, 1);
}
.tabs-kern__tab[aria-selected='true'] .tabs-kern__word { letter-spacing: 0.42em; margin-right: -0.42em; }
.tabs-kern__tab[aria-selected='true'] .tabs-kern__word::after { right: 0.42em; transform: scaleX(1); }
.tabs-kern__panel { padding: 16px 8px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-kern__word, .tabs-kern__word::after { transition: none; }
}function initTabsKern(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'; });
// The spacing, the underline and the reflow of the row are all CSS transitions on aria-selected.
function select(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-kern').forEach(initTabsKern);Goal: Create a "Kern" tab set.
Visual behavior: Collection tabs in small caps whose active label opens its letter spacing; the row reflows around it, so every other label slides aside to make room.
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 active label's letter-spacing transitions from 0.06em to 0.42em. Letter spacing also follows the last letter, so a matching negative right margin takes that trailing space back.
- Because the row is a centred flex line, the change reflows it on every frame, and the other labels slide aside with no extra code. An underline scales in under the active word.
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 spacing changes 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 "Kern" tab set.
Visual behavior: Collection tabs in small caps whose active label opens its letter spacing; the row reflows around it, so every other label slides aside to make room.
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 active label's letter-spacing transitions from 0.06em to 0.42em. Letter spacing also follows the last letter, so a matching negative right margin takes that trailing space back.
- Because the row is a centred flex line, the change reflows it on every frame, and the other labels slide aside with no extra code. An underline scales in under the active word.
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 spacing changes 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
About this component
Collection tabs in small caps whose active label opens its letter spacing; the row reflows around it, so every other label slides aside to make room.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change
- Techniques
- css transition, text animation
- 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 spacing changes at once.