All Taste components
Tabs
Scrollable tabs
A long row of tabs that scrolls sideways, with a fade at the edge and arrow buttons to move along.
edge fade · prev / next arrows · disabled arrow at the start · many tabs
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export function ScrollableTabs({ items, label, onChange }:
{ items: string[]; label: string; onChange?: (item: string) => void }) {
const [active, setActive] = useState(0);
const [edges, setEdges] = useState({ start: true, end: false });
const scroller = useRef<HTMLDivElement>(null);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const measure = () => {
const el = scroller.current;
if (!el) return;
setEdges({ start: el.scrollLeft <= 1, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 1 });
};
useEffect(() => {
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
const select = (i: number) => {
setActive(i);
onChange?.(items[i]);
refs.current[i]?.scrollIntoView({ block: "nearest", inline: "nearest" });
};
const page = (dir: 1 | -1) => scroller.current?.scrollBy({ left: dir * scroller.current.clientWidth * 0.7 });
const onKeyDown = (e: React.KeyboardEvent) => {
const last = items.length - 1;
const keys: Record<string, number> = {
ArrowRight: active === last ? 0 : active + 1,
ArrowLeft: active === 0 ? last : active - 1,
Home: 0,
End: last,
};
const next = keys[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
refs.current[next]?.focus();
};
const fade = edges.start && edges.end
? ""
: edges.start
? "[mask-image:linear-gradient(90deg,black_calc(100%-56px),transparent)]"
: edges.end
? "[mask-image:linear-gradient(90deg,transparent,black_56px)]"
: "[mask-image:linear-gradient(90deg,transparent,black_56px,black_calc(100%-56px),transparent)]";
return (
<div className="flex min-w-0 items-center gap-1.5 rounded-lg border border-border bg-bg p-1.5 font-sans">
<button type="button" aria-label="Scroll tabs left" disabled={edges.start} onClick={() => page(-1)}
className="grid size-8 shrink-0 place-items-center rounded-full border border-border text-fg hover:bg-surface disabled:opacity-40">
<ChevronLeft className="size-4" aria-hidden />
</button>
<div ref={scroller} onScroll={measure} role="tablist" aria-label={label}
className={cn("flex min-w-0 flex-1 gap-0.5 overflow-x-auto scroll-smooth p-0.5 [scrollbar-width:none]", fade)}>
{items.map((item, i) => (
<button
key={item}
ref={(el) => { refs.current[i] = el; }}
type="button"
role="tab"
aria-selected={active === i}
tabIndex={active === i ? 0 : -1}
onClick={() => select(i)}
onKeyDown={onKeyDown}
className={cn(
"h-8 shrink-0 whitespace-nowrap rounded px-3 text-[13.5px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
active === i ? "bg-surface-2 text-fg" : "text-fg-muted hover:bg-surface hover:text-fg",
)}
>
{item}
</button>
))}
</div>
<button type="button" aria-label="Scroll tabs right" disabled={edges.end} onClick={() => page(1)}
className="grid size-8 shrink-0 place-items-center rounded-full border border-border text-fg hover:bg-surface disabled:opacity-40">
<ChevronRight className="size-4" aria-hidden />
</button>
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.