Scroller
import { useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{ label: 'All', body: '1,284 pieces across every category.' },
{ label: 'Motion', body: 'Loops, transitions and kinetic type.' },
{ label: 'Interface', body: 'Buttons, menus and the small details.' },
{ label: 'Type', body: 'Lettering, layouts and specimens.' },
{ label: 'Illustration', body: 'Drawn by hand, mostly in ink.' },
{ label: '3D', body: 'Renders, materials and product shots.' },
{ label: 'Sound', body: 'Short scores and interface sounds.' },
{ label: 'Code', body: 'Generative sketches and shaders.' },
];
const css = `
.tabs-scroller { position: relative; width: 260px; }
.tabs-scroller__scroll {
overflow-x: auto;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--tabs-scroller-l), #000 calc(100% - var(--tabs-scroller-r)), transparent);
mask-image: linear-gradient(90deg, transparent, #000 var(--tabs-scroller-l), #000 calc(100% - var(--tabs-scroller-r)), transparent);
}
.tabs-scroller__scroll::-webkit-scrollbar { display: none; }
.tabs-scroller__list { position: relative; isolation: isolate; display: flex; gap: 4px; width: max-content; padding: 2px; }
.tabs-scroller__pill {
position: absolute;
top: 2px;
height: 32px;
border-radius: 999px;
background: light-dark(#373a40, #f1f2f3);
transition: left 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), width 0.45s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.tabs-scroller__tab { position: relative; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer; transition: color 0.25s; }
/* Each chip's outline sits beneath the pill (z-index -1 in the list's stacking context), so the pill slides over them cleanly. */
.tabs-scroller__tab::before { content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-scroller__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-scroller__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); transition-delay: 0.15s; }
.tabs-scroller__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
.tabs-scroller__arrow {
position: absolute;
top: 3px;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
box-shadow: 0 2px 8px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.4));
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
}
.tabs-scroller__arrow[data-hidden] { opacity: 0; transform: scale(0.7); pointer-events: none; }
.tabs-scroller__arrow svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.tabs-scroller__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-scroller__pill { transition: none; }
}
`;
export function ScrollerTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [box, setBox] = useState({ left: 0, width: 0 });
const [edges, setEdges] = useState({ start: true, end: false });
const scroll = useRef<HTMLDivElement>(null);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const smooth = () => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
const update = () => {
const s = scroll.current!;
setEdges({ start: s.scrollLeft < 2, end: s.scrollLeft > s.scrollWidth - s.clientWidth - 2 });
};
useLayoutEffect(() => {
const measure = () => {
const t = tabs.current[sel];
if (t) setBox({ left: t.offsetLeft, width: t.offsetWidth });
update();
};
measure();
document.fonts?.ready.then(measure);
}, [sel]);
const select = (i: number) => {
setSel(i);
const s = scroll.current!;
const t = tabs.current[i]!;
// The chosen tab glides to the middle of the row.
s.scrollTo({ left: t.offsetLeft + t.offsetWidth / 2 - s.clientWidth / 2, behavior: smooth() });
};
const page = (d: number) => scroll.current!.scrollBy({ left: d * 160, behavior: smooth() });
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowRight: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus({ preventScroll: true });
};
const chevron = (d: string) => (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d={d} />
</svg>
);
return (
<>
<style>
{css}
</style>
<div className="tabs-scroller">
<div
ref={scroll}
className="tabs-scroller__scroll"
onScroll={update}
style={{ '--tabs-scroller-l': edges.start ? '0px' : '40px', '--tabs-scroller-r': edges.end ? '0px' : '40px' } as React.CSSProperties}
>
<div className="tabs-scroller__list" role="tablist" aria-label="Category" onKeyDown={onKeyDown}>
<span className="tabs-scroller__pill" style={{ left: box.left, width: box.width }} aria-hidden="true" />
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-scroller__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>
{/* Pointer conveniences; keyboard users move along the tabs with the arrow keys. */}
<button className="tabs-scroller__arrow" style={{ left: 0 }} type="button" tabIndex={-1} aria-hidden="true" data-hidden={edges.start || undefined} onClick={() => page(-1)}>
{chevron('M10 3.5 5.5 8l4.5 4.5')}
</button>
<button className="tabs-scroller__arrow" style={{ right: 0 }} type="button" tabIndex={-1} aria-hidden="true" data-hidden={edges.end || undefined} onClick={() => page(1)}>
{chevron('M6 3.5 10.5 8 6 12.5')}
</button>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-scroller__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 initTabsScroller(el) on the mounted element instead.
<div class="tabs-scroller">
<div class="tabs-scroller__scroll" style="--tabs-scroller-l:0px;--tabs-scroller-r:40px">
<div class="tabs-scroller__list" role="tablist" aria-label="Category">
<span class="tabs-scroller__pill" style="left:0;width:0" aria-hidden="true"></span>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-0" aria-selected="true" aria-controls="tabs-scroller-panel-0" tabindex="0">All</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-1" aria-selected="false" aria-controls="tabs-scroller-panel-1" tabindex="-1">Motion</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-2" aria-selected="false" aria-controls="tabs-scroller-panel-2" tabindex="-1">Interface</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-3" aria-selected="false" aria-controls="tabs-scroller-panel-3" tabindex="-1">Type</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-4" aria-selected="false" aria-controls="tabs-scroller-panel-4" tabindex="-1">Illustration</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-5" aria-selected="false" aria-controls="tabs-scroller-panel-5" tabindex="-1">3D</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-6" aria-selected="false" aria-controls="tabs-scroller-panel-6" tabindex="-1">Sound</button>
<button class="tabs-scroller__tab" type="button" role="tab" id="tabs-scroller-tab-7" aria-selected="false" aria-controls="tabs-scroller-panel-7" tabindex="-1">Code</button>
</div>
</div>
<button class="tabs-scroller__arrow" style="left:0" type="button" tabindex="-1" aria-hidden="true" data-hidden="true">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 3.5 5.5 8l4.5 4.5"></path>
</svg>
</button>
<button class="tabs-scroller__arrow" style="right:0" type="button" tabindex="-1" aria-hidden="true">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 3.5 10.5 8 6 12.5"></path>
</svg>
</button>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-0" aria-labelledby="tabs-scroller-tab-0">1,284 pieces across every category.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-1" aria-labelledby="tabs-scroller-tab-1" hidden="">Loops, transitions and kinetic type.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-2" aria-labelledby="tabs-scroller-tab-2" hidden="">Buttons, menus and the small details.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-3" aria-labelledby="tabs-scroller-tab-3" hidden="">Lettering, layouts and specimens.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-4" aria-labelledby="tabs-scroller-tab-4" hidden="">Drawn by hand, mostly in ink.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-5" aria-labelledby="tabs-scroller-tab-5" hidden="">Renders, materials and product shots.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-6" aria-labelledby="tabs-scroller-tab-6" hidden="">Short scores and interface sounds.</div>
<div class="tabs-scroller__panel" role="tabpanel" id="tabs-scroller-panel-7" aria-labelledby="tabs-scroller-tab-7" hidden="">Generative sketches and shaders.</div>
</div>.tabs-scroller { position: relative; width: 260px; }
.tabs-scroller__scroll {
overflow-x: auto;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--tabs-scroller-l), #000 calc(100% - var(--tabs-scroller-r)), transparent);
mask-image: linear-gradient(90deg, transparent, #000 var(--tabs-scroller-l), #000 calc(100% - var(--tabs-scroller-r)), transparent);
}
.tabs-scroller__scroll::-webkit-scrollbar { display: none; }
.tabs-scroller__list { position: relative; isolation: isolate; display: flex; gap: 4px; width: max-content; padding: 2px; }
.tabs-scroller__pill {
position: absolute;
top: 2px;
height: 32px;
border-radius: 999px;
background: light-dark(#373a40, #f1f2f3);
transition: left 0.45s cubic-bezier(0.3, 1.25, 0.5, 1), width 0.45s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.tabs-scroller__tab { position: relative; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer; transition: color 0.25s; }
/* Each chip's outline sits beneath the pill (z-index -1 in the list's stacking context), so the pill slides over them cleanly. */
.tabs-scroller__tab::before { content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-scroller__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-scroller__tab[aria-selected='true'] { color: light-dark(#fbfcfd, #121417); transition-delay: 0.15s; }
.tabs-scroller__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
.tabs-scroller__arrow {
position: absolute;
top: 3px;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
box-shadow: 0 2px 8px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.4));
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
}
.tabs-scroller__arrow[data-hidden] { opacity: 0; transform: scale(0.7); pointer-events: none; }
.tabs-scroller__arrow svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.tabs-scroller__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-scroller__pill { transition: none; }
}function initTabsScroller(root) {
var scroll = root.querySelector('.tabs-scroller__scroll');
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var pill = root.querySelector('.tabs-scroller__pill');
var arrows = root.querySelectorAll('.tabs-scroller__arrow');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
function smooth() { return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; }
// Fade an edge and show its arrow only where there is more row to see.
function edges() {
var start = scroll.scrollLeft < 2;
var end = scroll.scrollLeft > scroll.scrollWidth - scroll.clientWidth - 2;
scroll.style.setProperty('--tabs-scroller-l', start ? '0px' : '40px');
scroll.style.setProperty('--tabs-scroller-r', end ? '0px' : '40px');
arrows[0].toggleAttribute('data-hidden', start);
arrows[1].toggleAttribute('data-hidden', end);
}
function place() {
var t = tabs[sel];
pill.style.left = t.offsetLeft + 'px';
pill.style.width = t.offsetWidth + 'px';
}
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; });
place();
// The chosen tab glides to the middle of the row.
var t = tabs[i];
scroll.scrollTo({ left: t.offsetLeft + t.offsetWidth / 2 - scroll.clientWidth / 2, behavior: smooth() });
}
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: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus({ preventScroll: true });
}
// Pointer conveniences; keyboard users move along the tabs with the arrow keys.
function back() { scroll.scrollBy({ left: -160, behavior: smooth() }); }
function on() { scroll.scrollBy({ left: 160, behavior: smooth() }); }
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
scroll.addEventListener('scroll', edges);
arrows[0].addEventListener('click', back);
arrows[1].addEventListener('click', on);
place();
edges();
if (document.fonts) document.fonts.ready.then(function () { place(); edges(); });
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
scroll.removeEventListener('scroll', edges);
arrows[0].removeEventListener('click', back);
arrows[1].removeEventListener('click', on);
};
}
document.querySelectorAll('.tabs-scroller').forEach(initTabsScroller);Goal: Create a "Scroller" tab set.
Visual behavior: More category tabs than fit: the row scrolls with faded edges and arrow buttons, and the one you pick glides to the middle under a sliding pill.
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 (stopping at either end), Home and End move between tabs and select as they go.
- The row scrolls natively with its scrollbar hidden. A mask fades each edge only while there is more to see that way, and the arrows show and hide with it.
- Selecting a tab scrolls it to the centre and moves a pill behind it, measured from the tab's offsetLeft and offsetWidth. The chips' outlines are pseudo-elements stacked under the pill, so it slides over them cleanly. The arrow buttons are for the pointer only; the arrow keys move along the tabs.
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 row scrolls and the pill moves without animating.
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 "Scroller" tab set.
Visual behavior: More category tabs than fit: the row scrolls with faded edges and arrow buttons, and the one you pick glides to the middle under a sliding pill.
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 (stopping at either end), Home and End move between tabs and select as they go.
- The row scrolls natively with its scrollbar hidden. A mask fades each edge only while there is more to see that way, and the arrows show and hide with it.
- Selecting a tab scrolls it to the centre and moves a pill behind it, measured from the tab's offsetLeft and offsetWidth. The chips' outlines are pseudo-elements stacked under the pill, so it slides over them cleanly. The arrow buttons are for the pointer only; the arrow keys move along the tabs.
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 row scrolls and the pill moves without animating.
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
More category tabs than fit: the row scrolls with faded edges and arrow buttons, and the one you pick glides to the middle under a sliding pill.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- css variables, css transition, pseudo elements
- 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 row scrolls and the pill moves without animating.