Stories
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Morning', title: 'Sunrise run', body: '5 km along the river, fog still on the water.' },
{ label: 'Lunch', title: 'Noodle bar', body: 'The spicy one, again. No regrets.' },
{ label: 'Evening', title: 'Rooftop', body: 'Friends, a slow sunset and one more song.' },
];
const css = `
@keyframes tabs-stories-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes tabs-stories-in { from { opacity: 0; transform: scale(1.03); } }
.tabs-stories { position: relative; width: 210px; height: 190px; overflow: hidden; border-radius: 16px; background: linear-gradient(170deg, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529) 70%); box-shadow: 0 14px 28px -16px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-stories__list { position: absolute; left: 10px; right: 38px; top: 10px; display: flex; gap: 4px; }
.tabs-stories__tab { position: relative; flex: 1; height: 3px; padding: 0; overflow: visible; border: 0; border-radius: 2px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); cursor: pointer; }
.tabs-stories__tab::before { content: ''; position: absolute; inset: -8px 0; }
.tabs-stories__fill { position: absolute; inset: 0; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); transform: scaleX(var(--tabs-stories-done)); transform-origin: 0 50%; }
.tabs-stories__tab[aria-selected='true'] .tabs-stories__fill { animation: tabs-stories-fill 4s linear forwards; }
.tabs-stories:hover .tabs-stories__fill, .tabs-stories:focus-within .tabs-stories__fill, .tabs-stories[data-paused] .tabs-stories__fill { animation-play-state: paused; }
.tabs-stories__tab:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: 4px; }
.tabs-stories__pause { position: absolute; right: 8px; top: 3px; display: grid; place-items: center; width: 24px; height: 18px; padding: 0; border: 0; border-radius: 5px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
.tabs-stories__pause:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); }
.tabs-stories__pause svg { width: 12px; height: 12px; fill: currentColor; }
.tabs-stories__panel { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px; background: linear-gradient(transparent, color-mix(in oklab, light-dark(#fbfcfd, #121417) 55%, transparent)); }
.tabs-stories[data-armed] .tabs-stories__panel { animation: tabs-stories-in 0.4s ease-out; }
.tabs-stories__title { display: block; margin-bottom: 4px; color: light-dark(#373a40, #f1f2f3); font-size: 16px; font-weight: 600; }
.tabs-stories__body { color: light-dark(#42454c, #e5e6e9); font-size: 12px; line-height: 1.45; }
@media (prefers-reduced-motion: reduce) {
.tabs-stories__tab[aria-selected='true'] .tabs-stories__fill { animation: none; transform: scaleX(1); }
.tabs-stories__panel { animation: none !important; }
}
`;
export function StoriesTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [armed, setArmed] = useState(false);
const [paused, setPaused] = useState(false);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number) => {
setArmed(true);
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-stories" data-armed={armed ? '' : undefined} data-paused={paused ? '' : undefined}>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-stories__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<span className="tabs-stories__title">{t.title}</span>
<span className="tabs-stories__body">{t.body}</span>
</div>
))}
<div className="tabs-stories__list" role="tablist" aria-label="Stories" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-stories__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-label={t.label}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{/* The bar fills while the story plays; when it is full the next story starts. */}
<span
className="tabs-stories__fill"
style={{ '--tabs-stories-done': i < sel ? 1 : 0 } as React.CSSProperties}
onAnimationEnd={() => select((sel + 1) % TABS.length)}
/>
</button>
))}
</div>
<button className="tabs-stories__pause" type="button" aria-label={paused ? 'Play stories' : 'Pause stories'} onClick={() => setPaused((p) => !p)}>
<svg viewBox="0 0 12 12" aria-hidden="true">
{paused ? <path d="M3 1.5v9l7.5-4.5Z" /> : <path d="M2.5 1.5h2.5v9H2.5ZM7 1.5h2.5v9H7Z" />}
</svg>
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsStories(el) on the mounted element instead.
<div class="tabs-stories">
<div class="tabs-stories__panel" role="tabpanel" id="tabs-stories-panel-0" aria-labelledby="tabs-stories-tab-0">
<span class="tabs-stories__title">Sunrise run</span>
<span class="tabs-stories__body">5 km along the river, fog still on the water.</span>
</div>
<div class="tabs-stories__panel" role="tabpanel" id="tabs-stories-panel-1" aria-labelledby="tabs-stories-tab-1" hidden="">
<span class="tabs-stories__title">Noodle bar</span>
<span class="tabs-stories__body">The spicy one, again. No regrets.</span>
</div>
<div class="tabs-stories__panel" role="tabpanel" id="tabs-stories-panel-2" aria-labelledby="tabs-stories-tab-2" hidden="">
<span class="tabs-stories__title">Rooftop</span>
<span class="tabs-stories__body">Friends, a slow sunset and one more song.</span>
</div>
<div class="tabs-stories__list" role="tablist" aria-label="Stories">
<button class="tabs-stories__tab" type="button" role="tab" id="tabs-stories-tab-0" aria-label="Morning" aria-selected="true" aria-controls="tabs-stories-panel-0" tabindex="0">
<span class="tabs-stories__fill" style="--tabs-stories-done:0"></span>
</button>
<button class="tabs-stories__tab" type="button" role="tab" id="tabs-stories-tab-1" aria-label="Lunch" aria-selected="false" aria-controls="tabs-stories-panel-1" tabindex="-1">
<span class="tabs-stories__fill" style="--tabs-stories-done:0"></span>
</button>
<button class="tabs-stories__tab" type="button" role="tab" id="tabs-stories-tab-2" aria-label="Evening" aria-selected="false" aria-controls="tabs-stories-panel-2" tabindex="-1">
<span class="tabs-stories__fill" style="--tabs-stories-done:0"></span>
</button>
</div>
<button class="tabs-stories__pause" type="button" aria-label="Pause stories">
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 1.5h2.5v9H2.5ZM7 1.5h2.5v9H7Z"></path>
</svg>
</button>
</div>@keyframes tabs-stories-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes tabs-stories-in { from { opacity: 0; transform: scale(1.03); } }
.tabs-stories { position: relative; width: 210px; height: 190px; overflow: hidden; border-radius: 16px; background: linear-gradient(170deg, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529) 70%); box-shadow: 0 14px 28px -16px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-stories__list { position: absolute; left: 10px; right: 38px; top: 10px; display: flex; gap: 4px; }
.tabs-stories__tab { position: relative; flex: 1; height: 3px; padding: 0; overflow: visible; border: 0; border-radius: 2px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); cursor: pointer; }
.tabs-stories__tab::before { content: ''; position: absolute; inset: -8px 0; }
.tabs-stories__fill { position: absolute; inset: 0; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); transform: scaleX(var(--tabs-stories-done)); transform-origin: 0 50%; }
.tabs-stories__tab[aria-selected='true'] .tabs-stories__fill { animation: tabs-stories-fill 4s linear forwards; }
.tabs-stories:hover .tabs-stories__fill, .tabs-stories:focus-within .tabs-stories__fill, .tabs-stories[data-paused] .tabs-stories__fill { animation-play-state: paused; }
.tabs-stories__tab:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: 4px; }
.tabs-stories__pause { position: absolute; right: 8px; top: 3px; display: grid; place-items: center; width: 24px; height: 18px; padding: 0; border: 0; border-radius: 5px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
.tabs-stories__pause:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); }
.tabs-stories__pause svg { width: 12px; height: 12px; fill: currentColor; }
.tabs-stories__panel { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px; background: linear-gradient(transparent, color-mix(in oklab, light-dark(#fbfcfd, #121417) 55%, transparent)); }
.tabs-stories[data-armed] .tabs-stories__panel { animation: tabs-stories-in 0.4s ease-out; }
.tabs-stories__title { display: block; margin-bottom: 4px; color: light-dark(#373a40, #f1f2f3); font-size: 16px; font-weight: 600; }
.tabs-stories__body { color: light-dark(#42454c, #e5e6e9); font-size: 12px; line-height: 1.45; }
@media (prefers-reduced-motion: reduce) {
.tabs-stories__tab[aria-selected='true'] .tabs-stories__fill { animation: none; transform: scaleX(1); }
.tabs-stories__panel { animation: none !important; }
}function initTabsStories(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'; });
var pause = root.querySelector('.tabs-stories__pause');
var icon = pause.querySelector('path');
var fills = tabs.map(function (tab) { return tab.querySelector('.tabs-stories__fill'); });
function select(i) {
// Armed on the first change, so nothing animates on load.
root.setAttribute('data-armed', '');
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; });
// Bars before the current story stay full.
fills.forEach(function (fill, k) { fill.style.setProperty('--tabs-stories-done', k < i ? 1 : 0); });
}
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();
}
// The bar fills while the story plays; when it is full the next story starts.
function onFilled(e) {
if (e.target.classList.contains('tabs-stories__fill')) select((sel + 1) % tabs.length);
}
function onPause() {
var paused = !root.hasAttribute('data-paused');
root.toggleAttribute('data-paused', paused);
pause.setAttribute('aria-label', paused ? 'Play stories' : 'Pause stories');
icon.setAttribute('d', paused ? 'M3 1.5v9l7.5-4.5Z' : 'M2.5 1.5h2.5v9H2.5ZM7 1.5h2.5v9H7Z');
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
list.addEventListener('animationend', onFilled);
pause.addEventListener('click', onPause);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
list.removeEventListener('animationend', onFilled);
pause.removeEventListener('click', onPause);
};
}
document.querySelectorAll('.tabs-stories').forEach(initTabsStories);Goal: Create a "Stories" tab set.
Visual behavior: Story tabs as progress bars: each bar fills while its story plays and moves on by itself, pausing while you hover or focus, with a pause button.
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 bar-shaped tabs carry aria-label.
- The selected bar fills on a 4s keyframe; animationend selects the next story. Bars before it stay full through a custom property.
- The fill is paused while the pointer or focus is on the component and whenever the pause button (with an aria-label that says what it will do) is on.
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. Auto-advance pauses on hover, on focus and with a labelled pause button. Under prefers-reduced-motion the stories never advance by themselves.
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 "Stories" tab set.
Visual behavior: Story tabs as progress bars: each bar fills while its story plays and moves on by itself, pausing while you hover or focus, with a pause button.
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 bar-shaped tabs carry aria-label.
- The selected bar fills on a 4s keyframe; animationend selects the next story. Bars before it stay full through a custom property.
- The fill is paused while the pointer or focus is on the component and whenever the pause button (with an aria-label that says what it will do) is on.
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. Auto-advance pauses on hover, on focus and with a labelled pause button. Under prefers-reduced-motion the stories never advance by themselves.
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
Story tabs as progress bars: each bar fills while its story plays and moves on by itself, pausing while you hover or focus, with a pause button.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, fill
- Techniques
- keyframes, css variables, state machine
- 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. Auto-advance pauses on hover, on focus and with a labelled pause button. Under prefers-reduced-motion the stories never advance by themselves.