All Taste components
Tabs
Tabs with a More menu
Underlined tabs that tuck the rest into a More menu when there is no room for them all.
visible tabs · More dropdown (open) · menu with hints · fewer visible tabs when narrow
More
Spring menu launch
4 of 9 tasks done · due 18 Apr
9 tasks
3 assigned to you, 1 overdue.
14 files
Latest: menu-draft-v3.pdf
Next up
Tasting session, Thu 10:00
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
type Tab = { id: string; label: string };
export function OverflowTabs({ tabs, visible = 3, value, onChange, label }: {
tabs: Tab[];
visible?: number;
value: string;
onChange: (id: string) => void;
label: string;
}) {
const [open, setOpen] = useState(false);
const shown = tabs.slice(0, visible);
const hidden = tabs.slice(visible);
const activeHidden = hidden.find((t) => t.id === value);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const menu = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => { if (!menu.current?.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
const index = shown.findIndex((t) => t.id === value);
const onKeyDown = (e: React.KeyboardEvent) => {
const last = shown.length - 1;
const from = index < 0 ? 0 : index;
const keys: Record<string, number> = {
ArrowRight: from === last ? 0 : from + 1,
ArrowLeft: from === 0 ? last : from - 1,
Home: 0,
End: last,
};
const next = keys[e.key];
if (next === undefined) return;
e.preventDefault();
onChange(shown[next].id);
refs.current[next]?.focus();
};
const tabClass = (selected: boolean) => cn(
"relative inline-flex h-[42px] shrink-0 items-center gap-1 whitespace-nowrap text-[13.5px] font-medium transition-colors",
"after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected ? "text-fg after:bg-primary" : "text-fg-muted hover:text-fg",
);
return (
<div className="flex items-center gap-5 font-sans shadow-[inset_0_-1px_0] shadow-border">
<div role="tablist" aria-label={label} className="flex min-w-0 gap-5">
{shown.map((t, i) => (
<button
key={t.id}
ref={(el) => { refs.current[i] = el; }}
type="button"
role="tab"
aria-selected={t.id === value}
tabIndex={t.id === value || (index < 0 && i === 0) ? 0 : -1}
onClick={() => onChange(t.id)}
onKeyDown={onKeyDown}
className={tabClass(t.id === value)}
>
{t.label}
</button>
))}
</div>
{hidden.length > 0 && (
<div ref={menu} className="relative shrink-0">
<button type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)} className={tabClass(Boolean(activeHidden))}>
{activeHidden ? activeHidden.label : "More"}
<ChevronDown className={cn("size-[15px] transition-transform", open && "rotate-180")} aria-hidden />
</button>
{open && (
<div role="menu" aria-label="More tabs" className="absolute right-0 top-full z-10 mt-1.5 grid min-w-[196px] rounded-lg border border-border bg-bg p-1 shadow-lg">
{hidden.map((t) => (
<button
key={t.id}
type="button"
role="menuitemradio"
aria-checked={t.id === value}
onClick={() => { onChange(t.id); setOpen(false); }}
className="flex h-[34px] items-center justify-between gap-3 rounded px-2.5 text-left text-[13.5px] text-fg hover:bg-surface-2 focus-visible:bg-surface-2 focus-visible:outline-none"
>
{t.label}
{t.id === value && <Check className="size-3.5 text-primary" aria-hidden />}
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.