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