Navigation

Tab Overflow

A measured tab strip that folds the tabs it cannot fit into a "More" menu, and never lets the selected tab be the one that disappears.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRovingTabindex } from "@/registry/hooks/use-roving-tabindex"

/** Entrance keyframes for the overflow menu. React 19 hoists and dedupes <style href>. */
const KEYFRAMES = `@keyframes zg-tab-overflow-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}`

/** Breathing room kept between the menu and the boundary it clamps into. */
const EDGE = 8
/** Gap between the trigger and the menu. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tab-overflow.json

Prompt

Build a React + TypeScript + Tailwind "TabOverflow" component: a tab strip that
measures itself and folds the tabs it cannot fit into a "More" menu. Uses
lucide-react for the chevron and a roving-tabindex hook for keyboard navigation.

Contract
- TabOverflowItem: { id, label, icon?: ReactNode, content?: ReactNode }.
  `label` is both the visible text and the accessible name; `icon` is decorative
  (aria-hidden); `content` is the panel body.
- TabOverflowProps extends React.HTMLAttributes<HTMLDivElement> (minus onChange):
  { tabs, value, onValueChange(id), label = "Tabs", moreLabel = "More tabs",
    onOverflowChange?(hiddenIds: string[]), className }.
- Controlled only: the component never holds the selection, it reports the id to
  select. forwardRef to the root; rest props spread onto it.
- onOverflowChange fires with the ids currently living in the menu, in source
  order — wire it to a counter, to analytics, or to a "customise tabs" affordance.

Behavior — measurement
- Render a hidden MEASURING LAYER holding one twin per tab plus one for the "More"
  trigger, each at its natural width, sharing the tab's exact class string. Widths
  come from the twins, never from the painted strip.
  - The layer is `visibility: hidden`, NEVER `display: none`: a display:none box
    has no geometry, so it cannot be measured at all. Nothing inside it is
    focusable or exposed (aria-hidden, plain spans), so the usual "visibility
    hidden kills focus" hazard does not apply here.
  - Wrap the layer in a 0x0 `overflow: hidden` shell. A hidden element still
    contributes SCROLLABLE overflow: without the shell, ten twins laid out 1211px
    wide inside a 375px viewport made document.scrollWidth 1211 and the whole page
    scrolled sideways. The shell clips that while leaving every twin's layout —
    and therefore its width — untouched.
- Measure in a useLayoutEffect, not useEffect. The re-render it triggers is
  flushed before the browser paints, so the FIRST PAINTED FRAME is already
  collapsed. Sampling every animation frame from before mount, a 520px container
  with 10 tabs reads 4,4,4,4… — a fully expanded frame is never painted.
- Because the measuring layer is independent of the split, the output can never
  feed back into the input: no oscillation, and no ResizeObserver loop warning.
- Re-measure on: a ResizeObserver over the row AND over every twin (a late web
  font, a zoom change or an edited label all move a twin), plus
  document.fonts.ready, plus window resize. Observer callbacks schedule through
  requestAnimationFrame; the first synchronous pass is the layout effect's.
- Compare metrics before storing them and bail when nothing moved, and ceil every
  measured width — a fractional width that wobbles by a rounding error would keep
  producing "new" metrics forever.
- NO MARGINS on any tab. getBoundingClientRect reports the border box, so a margin
  would be spent in layout but missing from the budget. Space with the flex gap,
  which IS read back from the computed style.

Behavior — the split
- Walk `take` down from tabs.length to 0. The candidate visible set is the first
  `take` tabs, PLUS the selected tab appended if its index is past `take`. Cost it
  as sum(widths) + gap*(n-1), plus gap + triggerWidth whenever anything is left
  over. Stop at the first candidate that fits; restarting from "all visible" every
  pass is what makes the answer a pure function of the measurements.
- Two consequences that are the whole point of the component:
  1. THE SELECTED TAB IS NEVER COLLAPSED. It is pinned into the strip whatever its
     position, so "the current tab is hidden inside a menu" cannot happen by
     construction — no badge on the trigger, no reordering, no guessing. Measured
     at 320px with the 9th of 10 tabs selected: the strip renders
     `Overview | Webhooks`, the menu holds the other eight, and Webhooks is the one
     carrying aria-selected.
  2. THE TRIGGER PAYS FOR ITSELF. Only `take === tabs.length` is costed without a
     trigger, so a strip that fits exactly renders no trigger at all, and hiding one
     narrow tab to make room for a wider trigger is never chosen. Measured: ten tabs
     whose natural width is 985px render all ten and no trigger at exactly 985px.
     With a deliberately narrow trailing tab ("AI", 39px) the set needs 943px; at
     942px, hiding only that tab saves 43px but the trigger costs 79px, so the
     nine-tab answer would need 979px and overflow — the component drops two tabs
     (892px) instead. A "remove items until it fits" loop that forgets the trigger's
     own width ships the overflowing strip here.
- Keep the trigger's own width independent of the count it reports: the visible
  word stays "More", the count rides in the accessible name (`More tabs (8)`). A
  trigger that grew from "2 more" to "10 more" would change the very budget that
  produced the count.
- Degenerate case (container narrower than one tab plus the trigger): keep the
  selected tab and let the strip clip it. Losing the current tab is worse than
  losing a few pixels of its label.
- The strip is `w-full min-w-0 overflow-hidden`: its width is the input to the
  maths, so it must come from the parent and never from the content, and anything
  that cannot fit is clipped instead of blowing the page out sideways.

Behavior — semantics and keyboard
- Real tab semantics: `role="tablist"` (aria-orientation horizontal) around
  `role="tab"` buttons with aria-selected, each aria-controls'ing a panel that
  really exists. Render EVERY panel and put the `hidden` attribute on the inactive
  ones — a strip that mounts only the active panel leaves every other tab pointing
  at nothing, and `hidden` is display:none so the inactive panels cannot become an
  invisible keyboard trap either. Panels get aria-labelledby back to their tab.
- Roving tabindex: the strip is ONE tab stop. Arrow keys use AUTOMATIC ACTIVATION
  (move and select), which is exactly what makes the collapsed tabs reachable —
  selecting a collapsed tab pins it into the strip, and the focus request must
  survive the re-render that mounts it (keep the pending focus and retry on the
  next commit; focusing a node that is not yet mounted fails silently). Measured:
  ArrowRight nine times from the first tab visits all ten labels in source order
  while the strip re-splits underneath, with exactly one tabIndex=0 throughout.
- The collapsed tabs are rows in a `role="menu"` as `role="menuitemradio"`, NOT as
  `role="tab"`: a tab outside its tablist is a lie to assistive tech. What the rows
  really are is an exclusive choice of which tab to show. Menu keyboard: up/down
  cycle, Home/End jump, Escape closes and returns focus to the trigger (and
  stopPropagation, so a strip inside another overlay only closes the innermost
  layer). Do not intercept Tab — closing inside the keydown would unmount the
  focused row before the browser computed the next stop and strand focus on
  `<body>`; close on focusout instead, ignoring a null relatedTarget. Outside
  pointerdown dismisses.
- Adjust state during render, not in an effect: when the last collapsed tab comes
  back the trigger unmounts, and a menu left `open` would spring open on its own
  the next time the container narrowed.

Rendering & styling
- Semantic tokens only: text-muted-foreground for idle tabs, text-foreground for
  the selected one and on hover, bg-primary for the active underline, border for
  the strip rule, bg-popover / text-popover-foreground / shadow-md for the menu,
  hover:bg-accent hover:text-accent-foreground on menu rows,
  focus-visible:ring-2 ring-ring ring-inset everywhere (inset, because the strip
  clips its own overflow and an outset ring on the end tab would be cut in half).
  cn() merges the consumer className onto the root.
- NOTHING about a tab's box may change with selection. A bolder or wider selected
  tab would move every other tab and restart the maths on every click, so the
  selected state is painted with colour plus an absolutely positioned 2px rule —
  both width-neutral — and the twin can therefore share one class string with the
  real thing (measured delta: 0px on every tab at every width).
- Menu entrance keyframes ship in a React 19 hoisted `<style href>` (deduped across
  instances) and are disabled under motion-reduce, as is the chevron rotation.
  Nothing about the collapse, the menu or the keyboard depends on animation.

Customization levers
- The collapse policy is "source order, selected pinned". Swap the leading-run rule
  for a per-tab priority (like a toolbar) if some tabs must outlive others; keep the
  pin, it is what makes the keyboard path work.
- Chrome: the strip is one border-bottom plus an underline rule. Pills
  (bg-muted p-1 on the row, bg-background on the selected tab) or a boxed look are a
  className change on TAB_BOX — just keep every state the same width.
- Density: TAB_BOX's px-3 py-2 text-sm and the row's gap-1 are the only inputs to
  how much fits; loosen or tighten them and the split follows with no other change.
- Trigger: rename it via moreLabel, or swap "More" + chevron for an ellipsis glyph.
  Keep its width constant across counts.
- icon per tab makes a strip denser to read but wider to fit — the maths absorbs it,
  your budget does not.
- onOverflowChange for a live "6 in the strip · 4 in the menu" readout, or to
  persist which sections a user actually sees.
- Panels are ordinary children: drop routes, forms or lazy boundaries into
  `content` and keep the aria-controls wiring for free.

Concepts

  • Measuring twins, not the painted strip — a hidden layer holds every tab at its natural width, so the widths of all ten are readable while the strip already shows only the six that fit. There is never a frame in which the strip must be fully expanded in order to be measured, and the measurement can never be disturbed by its own result.
  • visibility: hidden, never display: none — a display: none box has no geometry at all, so it cannot be measured; that is the trap this whole pattern exists to avoid. The price is that a hidden box still contributes scrollable overflow, which is why the layer sits inside a 0×0 clipping shell.
  • First paint is already collapsed — the pass runs in a layout effect, so the re-render lands before the browser paints. Sampled every animation frame from before mount, a 520px strip of 10 tabs reads 4, 4, 4… and never 10.
  • Selected-tab pinning — the current tab is added to the visible set whatever its position, so the classic failure of this pattern (your active tab is hidden inside a menu) is impossible by construction rather than patched with a badge. It also doubles as the keyboard story: arrowing onto a collapsed tab selects it, which reveals it.
  • The trigger is in the budget — every candidate split except "everything visible" is costed with the "More" button's own width, so a strip that fits exactly renders no trigger, and hiding one narrow tab to pay for a wider trigger is never the chosen answer.
  • Menu rows are menuitemradio, not tabs — a role="tab" living outside its tablist misreports the structure to assistive tech. The rows are what they actually are: an exclusive choice of which tab to bring back.

On This Page