Cliff paths, a cold sea and chips on the harbour wall.
Old beech woods with a river to follow all day.
Miles of sand, and the stars at night are something else.
Hut to hut along the ridge, with snow into June.
import { useEffect, useId, useRef, useState } from 'react';
// Each tab's page, sketched small: [width %, kind] rows, and the card's size for it.
const TABS = [
{
label: 'Overview',
w: 132,
h: 66,
rows: [
[70, 'head'],
[92, 'line'],
[60, 'line'],
[100, 'box'],
],
body: 'What the product does, in two minutes, with a short film.',
},
{
label: 'Pricing',
w: 156,
h: 74,
rows: [
[50, 'head'],
[100, 'cols'],
],
body: 'Three plans. Teams of three or more get one invoice.',
},
{
label: 'Docs',
w: 118,
h: 70,
rows: [
[60, 'head'],
[90, 'line'],
[80, 'line'],
[86, 'line'],
[50, 'line'],
],
body: 'Guides, the API reference and a changelog going back to 1.0.',
},
{
label: 'Blog',
w: 144,
h: 62,
rows: [
[100, 'box'],
[75, 'head'],
[55, 'line'],
],
body: 'Notes from the team. The latest is about rewriting the editor.',
},
] as const;
const W = 240;
const COL = W / TABS.length;
const TOP = 84;
const css = `
.tabs-preview { position: relative; width: ${W}px; padding-top: ${TOP + 6}px; }
/* One card glides and resizes between tabs instead of a tooltip per tab. */
.tabs-preview__card {
position: absolute;
top: 0;
box-sizing: border-box;
padding: 9px;
border-radius: 10px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 10px 24px -10px var(--tabs-preview-shadow), inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
opacity: 0;
transform: translateY(4px) scale(0.96);
transform-origin: 50% 100%;
transition: left 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), top 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), width 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), height 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.18s, transform 0.18s;
pointer-events: none;
}
.tabs-preview { --tabs-preview-shadow: light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); }
.tabs-preview__card--on { opacity: 1; transform: none; }
.tabs-preview__page { display: grid; gap: 5px; animation: tabs-preview-in 0.22s ease-out; }
@keyframes tabs-preview-in {
from { opacity: 0; }
}
.tabs-preview__head { height: 7px; border-radius: 2px; background: light-dark(#5f626a, #a7abb2); }
.tabs-preview__line { height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.tabs-preview__box { height: 22px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.tabs-preview__cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; height: 40px; }
.tabs-preview__cols span { border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.tabs-preview__cols span:nth-child(2) { background: light-dark(#6d7179, #7d8087); }
.tabs-preview__nub { position: absolute; top: ${TOP - 6}px; width: 10px; height: 10px; margin-left: -5px; background: light-dark(#ebedf0, #232529); transform: rotate(45deg); opacity: 0; transition: left 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.18s; pointer-events: none; }
.tabs-preview__nub--on { opacity: 1; }
.tabs-preview__list { position: relative; display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); border-bottom: 1px solid light-dark(#ebedf0, #232529); }
.tabs-preview__tab { border: 0; padding: 6px 0 9px; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-preview__tab:hover, .tabs-preview__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-preview__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-preview__line-under { position: absolute; bottom: -1px; height: 2px; background: light-dark(#373a40, #f1f2f3); transition: left 0.3s cubic-bezier(0.3, 1.2, 0.5, 1); }
.tabs-preview__panel { padding: 10px 0 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-preview__card, .tabs-preview__nub, .tabs-preview__line-under { transition: opacity 0.18s; }
.tabs-preview__page { animation: none; }
}
`;
export function PreviewTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [peek, setPeek] = useState<{ i: number; on: boolean }>({ i: 0, on: false });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
// The first hover waits a moment before the card shows; after that it follows at once from tab to tab.
const enter = (i: number) => {
clearTimeout(timer.current);
if (peek.on) return setPeek({ i, on: true });
timer.current = window.setTimeout(() => setPeek({ i, on: true }), 160);
};
const leave = () => {
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setPeek((p) => ({ ...p, on: false })), 120);
};
const t = TABS[peek.i];
const x = Math.max(0, Math.min(W - t.w, peek.i * COL + COL / 2 - t.w / 2));
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-preview">
<div className={`tabs-preview__card${peek.on ? ' tabs-preview__card--on' : ''}`} style={{ left: x, top: TOP - t.h, width: t.w, height: t.h }} aria-hidden="true">
<div key={peek.i} className="tabs-preview__page">
{t.rows.map(([w, kind], r) =>
kind === 'cols' ? (
<span key={r} className="tabs-preview__cols">
<span />
<span />
<span />
</span>
) : (
<span key={r} className={`tabs-preview__${kind}`} style={{ width: `${w}%` }} />
),
)}
</div>
</div>
<span className={`tabs-preview__nub${peek.on ? ' tabs-preview__nub--on' : ''}`} style={{ left: peek.i * COL + COL / 2 }} aria-hidden="true" />
<div className="tabs-preview__list" role="tablist" aria-label="Site" onKeyDown={onKeyDown} onPointerLeave={leave}>
{TABS.map((tab, i) => (
<button
key={tab.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-preview__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onPointerEnter={(e) => e.pointerType === 'mouse' && enter(i)}
onClick={() => setSel(i)}
>
{tab.label}
</button>
))}
<span className="tabs-preview__line-under" style={{ left: sel * COL + 14, width: COL - 28 }} aria-hidden="true" />
</div>
{TABS.map((tab, i) => (
<div key={tab.label} className="tabs-preview__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{tab.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsPreview(el) on the mounted element instead.
<div class="tabs-preview">
<div class="tabs-preview__card" style="left:0;top:18px;width:132px;height:66px" aria-hidden="true">
<div class="tabs-preview__page">
<span class="tabs-preview__head" style="width:70%"></span>
<span class="tabs-preview__line" style="width:92%"></span>
<span class="tabs-preview__line" style="width:60%"></span>
<span class="tabs-preview__box" style="width:100%"></span>
</div>
</div>
<span class="tabs-preview__nub" style="left:30px" aria-hidden="true"></span>
<div class="tabs-preview__list" role="tablist" aria-label="Site">
<button class="tabs-preview__tab" type="button" role="tab" id="tabs-preview-tab-0" aria-selected="true" aria-controls="tabs-preview-panel-0" tabindex="0">Overview</button>
<button class="tabs-preview__tab" type="button" role="tab" id="tabs-preview-tab-1" aria-selected="false" aria-controls="tabs-preview-panel-1" tabindex="-1">Pricing</button>
<button class="tabs-preview__tab" type="button" role="tab" id="tabs-preview-tab-2" aria-selected="false" aria-controls="tabs-preview-panel-2" tabindex="-1">Docs</button>
<button class="tabs-preview__tab" type="button" role="tab" id="tabs-preview-tab-3" aria-selected="false" aria-controls="tabs-preview-panel-3" tabindex="-1">Blog</button>
<span class="tabs-preview__line-under" style="left:14px;width:32px" aria-hidden="true"></span>
</div>
<div class="tabs-preview__panel" role="tabpanel" id="tabs-preview-panel-0" aria-labelledby="tabs-preview-tab-0">What the product does, in two minutes, with a short film.</div>
<div class="tabs-preview__panel" role="tabpanel" id="tabs-preview-panel-1" aria-labelledby="tabs-preview-tab-1" hidden="">Three plans. Teams of three or more get one invoice.</div>
<div class="tabs-preview__panel" role="tabpanel" id="tabs-preview-panel-2" aria-labelledby="tabs-preview-tab-2" hidden="">Guides, the API reference and a changelog going back to 1.0.</div>
<div class="tabs-preview__panel" role="tabpanel" id="tabs-preview-panel-3" aria-labelledby="tabs-preview-tab-3" hidden="">Notes from the team. The latest is about rewriting the editor.</div>
</div>.tabs-preview { position: relative; width: 240px; padding-top: 90px; }
/* One card glides and resizes between tabs instead of a tooltip per tab. */
.tabs-preview__card {
position: absolute;
top: 0;
box-sizing: border-box;
padding: 9px;
border-radius: 10px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 10px 24px -10px var(--tabs-preview-shadow), inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
opacity: 0;
transform: translateY(4px) scale(0.96);
transform-origin: 50% 100%;
transition: left 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), top 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), width 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), height 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.18s, transform 0.18s;
pointer-events: none;
}
.tabs-preview { --tabs-preview-shadow: light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); }
.tabs-preview__card--on { opacity: 1; transform: none; }
.tabs-preview__page { display: grid; gap: 5px; animation: tabs-preview-in 0.22s ease-out; }
@keyframes tabs-preview-in {
from { opacity: 0; }
}
.tabs-preview__head { height: 7px; border-radius: 2px; background: light-dark(#5f626a, #a7abb2); }
.tabs-preview__line { height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); }
.tabs-preview__box { height: 22px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.tabs-preview__cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; height: 40px; }
.tabs-preview__cols span { border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.tabs-preview__cols span:nth-child(2) { background: light-dark(#6d7179, #7d8087); }
.tabs-preview__nub { position: absolute; top: 78px; width: 10px; height: 10px; margin-left: -5px; background: light-dark(#ebedf0, #232529); transform: rotate(45deg); opacity: 0; transition: left 0.28s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.18s; pointer-events: none; }
.tabs-preview__nub--on { opacity: 1; }
.tabs-preview__list { position: relative; display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid light-dark(#ebedf0, #232529); }
.tabs-preview__tab { border: 0; padding: 6px 0 9px; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-preview__tab:hover, .tabs-preview__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-preview__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-preview__line-under { position: absolute; bottom: -1px; height: 2px; background: light-dark(#373a40, #f1f2f3); transition: left 0.3s cubic-bezier(0.3, 1.2, 0.5, 1); }
.tabs-preview__panel { padding: 10px 0 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-preview__card, .tabs-preview__nub, .tabs-preview__line-under { transition: opacity 0.18s; }
.tabs-preview__page { animation: none; }
}function initTabsPreview(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 W = 240;
var COL = W / n;
var TOP = 84;
// A sketch of each page for the hover card: its size, then rows of [width %, kind].
var PAGES = [
{ w: 132, h: 66, rows: [[70, 'head'], [92, 'line'], [60, 'line'], [100, 'box']] },
{ w: 156, h: 74, rows: [[50, 'head'], [100, 'cols']] },
{ w: 118, h: 70, rows: [[60, 'head'], [90, 'line'], [80, 'line'], [86, 'line'], [50, 'line']] },
{ w: 144, h: 62, rows: [[100, 'box'], [75, 'head'], [55, 'line']] },
];
var card = root.querySelector('.tabs-preview__card');
var nub = root.querySelector('.tabs-preview__nub');
var under = root.querySelector('.tabs-preview__line-under');
var peek = { i: 0, on: false };
var timer = 0;
// The card glides and resizes to the tab's page; its sketch is redrawn so it fades in anew.
function show() {
var p = PAGES[peek.i];
card.style.left = Math.max(0, Math.min(W - p.w, peek.i * COL + COL / 2 - p.w / 2)) + 'px';
card.style.top = TOP - p.h + 'px';
card.style.width = p.w + 'px';
card.style.height = p.h + 'px';
card.classList.toggle('tabs-preview__card--on', peek.on);
nub.style.left = peek.i * COL + COL / 2 + 'px';
nub.classList.toggle('tabs-preview__nub--on', peek.on);
}
function sketch() {
var page = document.createElement('div');
page.className = 'tabs-preview__page';
PAGES[peek.i].rows.forEach(function (row) {
var el = document.createElement('span');
el.className = 'tabs-preview__' + row[1];
if (row[1] === 'cols') el.innerHTML = '<span></span><span></span><span></span>';
else el.style.width = row[0] + '%';
page.appendChild(el);
});
card.replaceChild(page, card.firstElementChild);
}
function peekAt(i) {
var moved = i !== peek.i;
peek = { i: i, on: true };
if (moved) sketch();
show();
}
// The first hover waits a moment before the card shows; after that it follows at once from tab to tab.
function onEnter(e) {
if (e.pointerType !== 'mouse') return;
var i = tabs.indexOf(e.currentTarget);
clearTimeout(timer);
if (peek.on) return peekAt(i);
timer = setTimeout(function () { peekAt(i); }, 160);
}
function onLeave() {
clearTimeout(timer);
timer = setTimeout(function () {
peek.on = false;
show();
}, 120);
}
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; });
under.style.left = i * COL + 14 + 'px';
}
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);
tabs.forEach(function (tab) { tab.addEventListener('pointerenter', onEnter); });
list.addEventListener('pointerleave', onLeave);
return function () {
clearTimeout(timer);
tabs.forEach(function (tab) { tab.removeEventListener('pointerenter', onEnter); });
list.removeEventListener('pointerleave', onLeave);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-preview').forEach(initTabsPreview);Goal: Create a "Preview" tab set.
Visual behavior: Site tabs with a hover card that sketches each page: the card glides and resizes from tab to tab instead of reappearing, then fades when you leave.
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.
- One card serves every tab. Its left, top, width and height transition to the hovered tab's page size, and its sketch is redrawn to fade in anew.
- The first hover waits 160ms before showing the card; once it is up, moving between tabs follows at once, and leaving fades it after a short grace period.
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 hover card is aria-hidden: it previews what the tabs already name. Under prefers-reduced-motion the card fades without gliding.
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 "Preview" tab set.
Visual behavior: Site tabs with a hover card that sketches each page: the card glides and resizes from tab to tab instead of reappearing, then fades when you leave.
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.
- One card serves every tab. Its left, top, width and height transition to the hovered tab's page size, and its sketch is redrawn to fade in anew.
- The first hover waits 160ms before showing the card; once it is up, moving between tabs follows at once, and leaving fades it after a short grace period.
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 hover card is aria-hidden: it previews what the tabs already name. Under prefers-reduced-motion the card fades without gliding.
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
Site tabs with a hover card that sketches each page: the card glides and resizes from tab to tab instead of reappearing, then fades when you leave.
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 hover card is aria-hidden: it previews what the tabs already name. Under prefers-reduced-motion the card fades without gliding.
Cliff paths, a cold sea and chips on the harbour wall.
Old beech woods with a river to follow all day.
Miles of sand, and the stars at night are something else.
Hut to hut along the ridge, with snow into June.
2,481 photos. The latest is from this morning, at the harbour.
Playing Tides. Three albums saved for the train.
Home in 24 minutes by bike. Light rain after four.
Poster sizes, the ink order and a list of printers to call.