Fit
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{
label: 'Profile',
rows: [
['Name', 'Mira Holt'],
['Email', 'mira@studio.co'],
],
},
{
label: 'Alerts',
rows: [
['Mentions', 'On'],
['Comments', 'On'],
['New followers', 'Off'],
['Weekly digest', 'On'],
],
},
{ label: 'Privacy', rows: [['Your profile is public. Drafts stay private until you publish them.', '']] },
];
const W = 240;
const COL = W / TABS.length;
const css = `
.tabs-fit { width: ${W}px; }
.tabs-fit__list { position: relative; display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); margin-bottom: 10px; }
.tabs-fit__tab { border: 0; padding: 6px 0 9px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-fit__tab:hover, .tabs-fit__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-fit__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-fit__line { position: absolute; bottom: 0; height: 2px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); transition: left 0.35s cubic-bezier(0.3, 1.2, 0.5, 1); }
/* The box eases to the height of whatever it holds, so the page below moves smoothly too. */
.tabs-fit__box { position: relative; overflow: hidden; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: height 0.4s cubic-bezier(0.3, 1.1, 0.4, 1); }
.tabs-fit__panel { padding: 6px 12px; }
.tabs-fit__panel--out { position: absolute; left: 0; right: 0; top: 0; animation: tabs-fit-out 0.2s ease-in forwards; pointer-events: none; }
@keyframes tabs-fit-out {
to { opacity: 0; filter: blur(2px); }
}
.tabs-fit__row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 12.5px; line-height: 1.45; color: light-dark(#50535b, #c6c8cd); animation: tabs-fit-row 0.35s ease-out backwards; }
.tabs-fit__row + .tabs-fit__row { border-top: 1px solid light-dark(#ebedf0, #232529); }
.tabs-fit__row b { font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.tabs-fit__panel--out .tabs-fit__row { animation: none; }
@keyframes tabs-fit-row {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-fit__box, .tabs-fit__line { transition: none; }
.tabs-fit__row { animation: none; }
.tabs-fit__panel--out { display: none; }
}
`;
export function FitTabs() {
const id = useId();
const [s, setS] = useState({ sel: 0, out: -1 });
const [h, setH] = useState<number | undefined>(undefined);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const panels = useRef<(HTMLDivElement | null)[]>([]);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
// Measure the new panel before paint and hand that height to the box to ease towards.
useLayoutEffect(() => {
setH(panels.current[s.sel]!.offsetHeight);
}, [s.sel]);
const pick = (i: number) => {
if (i === s.sel) return;
setS({ sel: i, out: s.sel });
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setS((o) => ({ ...o, out: -1 })), 220);
};
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (s.sel + 1) % n, ArrowLeft: (s.sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-fit">
<div className="tabs-fit__list" role="tablist" aria-label="Settings" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-fit__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={s.sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={s.sel === i ? 0 : -1}
onClick={() => pick(i)}
>
{t.label}
</button>
))}
<span className="tabs-fit__line" style={{ left: s.sel * COL + 16, width: COL - 32 }} aria-hidden="true" />
</div>
<div className="tabs-fit__box" style={{ height: h }}>
{TABS.map((t, i) => (
<div
key={t.label}
ref={(el) => {
panels.current[i] = el;
}}
className={`tabs-fit__panel${i === s.out ? ' tabs-fit__panel--out' : ''}`}
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
aria-hidden={i === s.out || undefined}
hidden={i !== s.sel && i !== s.out}
>
{t.rows.map(([k, v], r) => (
<div key={k} className="tabs-fit__row" style={{ animationDelay: `${r * 45}ms` }}>
{k}
{v && <b>{v}</b>}
</div>
))}
</div>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsFit(el) on the mounted element instead.
<div class="tabs-fit">
<div class="tabs-fit__list" role="tablist" aria-label="Settings">
<button class="tabs-fit__tab" type="button" role="tab" id="tabs-fit-tab-0" aria-selected="true" aria-controls="tabs-fit-panel-0" tabindex="0">Profile</button>
<button class="tabs-fit__tab" type="button" role="tab" id="tabs-fit-tab-1" aria-selected="false" aria-controls="tabs-fit-panel-1" tabindex="-1">Alerts</button>
<button class="tabs-fit__tab" type="button" role="tab" id="tabs-fit-tab-2" aria-selected="false" aria-controls="tabs-fit-panel-2" tabindex="-1">Privacy</button>
<span class="tabs-fit__line" style="left:16px;width:48px" aria-hidden="true"></span>
</div>
<div class="tabs-fit__box">
<div class="tabs-fit__panel" role="tabpanel" id="tabs-fit-panel-0" aria-labelledby="tabs-fit-tab-0">
<div class="tabs-fit__row" style="animation-delay:0ms">
Name
<b>Mira Holt</b>
</div>
<div class="tabs-fit__row" style="animation-delay:45ms">
Email
<b>mira@studio.co</b>
</div>
</div>
<div class="tabs-fit__panel" role="tabpanel" id="tabs-fit-panel-1" aria-labelledby="tabs-fit-tab-1" hidden="">
<div class="tabs-fit__row" style="animation-delay:0ms">
Mentions
<b>On</b>
</div>
<div class="tabs-fit__row" style="animation-delay:45ms">
Comments
<b>On</b>
</div>
<div class="tabs-fit__row" style="animation-delay:90ms">
New followers
<b>Off</b>
</div>
<div class="tabs-fit__row" style="animation-delay:135ms">
Weekly digest
<b>On</b>
</div>
</div>
<div class="tabs-fit__panel" role="tabpanel" id="tabs-fit-panel-2" aria-labelledby="tabs-fit-tab-2" hidden="">
<div class="tabs-fit__row" style="animation-delay:0ms">Your profile is public. Drafts stay private until you publish them.</div>
</div>
</div>
</div>.tabs-fit { width: 240px; }
.tabs-fit__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 10px; }
.tabs-fit__tab { border: 0; padding: 6px 0 9px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-fit__tab:hover, .tabs-fit__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-fit__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-fit__line { position: absolute; bottom: 0; height: 2px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); transition: left 0.35s cubic-bezier(0.3, 1.2, 0.5, 1); }
/* The box eases to the height of whatever it holds, so the page below moves smoothly too. */
.tabs-fit__box { position: relative; overflow: hidden; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: height 0.4s cubic-bezier(0.3, 1.1, 0.4, 1); }
.tabs-fit__panel { padding: 6px 12px; }
.tabs-fit__panel--out { position: absolute; left: 0; right: 0; top: 0; animation: tabs-fit-out 0.2s ease-in forwards; pointer-events: none; }
@keyframes tabs-fit-out {
to { opacity: 0; filter: blur(2px); }
}
.tabs-fit__row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 12.5px; line-height: 1.45; color: light-dark(#50535b, #c6c8cd); animation: tabs-fit-row 0.35s ease-out backwards; }
.tabs-fit__row + .tabs-fit__row { border-top: 1px solid light-dark(#ebedf0, #232529); }
.tabs-fit__row b { font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.tabs-fit__panel--out .tabs-fit__row { animation: none; }
@keyframes tabs-fit-row {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-fit__box, .tabs-fit__line { transition: none; }
.tabs-fit__row { animation: none; }
.tabs-fit__panel--out { display: none; }
}function initTabsFit(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var COL = 240 / n;
var box = root.querySelector('.tabs-fit__box');
var line = root.querySelector('.tabs-fit__line');
var timer = 0;
var out = -1;
function settle() {
panels.forEach(function (panel, k) {
panel.classList.toggle('tabs-fit__panel--out', k === out);
if (k === out) panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
panel.hidden = k !== sel && k !== out;
});
}
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;
});
// The old panel blurs out on top while the new rows come in; the box eases to the new panel's height.
out = prev;
settle();
box.style.height = panels[i].offsetHeight + 'px';
line.style.left = i * COL + 16 + 'px';
clearTimeout(timer);
timer = setTimeout(function () {
out = -1;
settle();
}, 220);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
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);
box.style.height = panels[sel].offsetHeight + 'px';
return function () {
clearTimeout(timer);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-fit').forEach(initTabsFit);Goal: Create a "Fit" tab set.
Visual behavior: Settings tabs whose box eases to the height of each panel, the old content blurring out as the new rows fade in one after another.
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 new panel is measured before paint and its height handed to the box, whose height transitions there.
- For a moment the old panel stays on top, blurring and fading out, while the new rows rise in with a stagger.
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. The outgoing panel is aria-hidden while it fades. Under prefers-reduced-motion the box and rows 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 "Fit" tab set.
Visual behavior: Settings tabs whose box eases to the height of each panel, the old content blurring out as the new rows fade in one after another.
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 new panel is measured before paint and its height handed to the box, whose height transitions there.
- For a moment the old panel stays on top, blurring and fading out, while the new rows rise in with a stagger.
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. The outgoing panel is aria-hidden while it fades. Under prefers-reduced-motion the box and rows 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
About this component
Settings tabs whose box eases to the height of each panel, the old content blurring out as the new rows fade in one after another.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, delay stagger
- Techniques
- css transition, filter, stagger
- 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. The outgoing panel is aria-hidden while it fades. Under prefers-reduced-motion the box and rows change at once.