Skip to content
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.