Tab Strip
The browser-style row of open documents across the top of an app. Each tab carries an icon, a label and a close button, closes on a middle click or Delete, and the strip scrolls instead of wrapping. Presentation only: your app keeps the list, and any drag-and-drop library can reorder it.
Installation#
Usage#
import { TabStrip, TabStripAdd, TabStripItem } from "@/components/ui/tab-strip"
export function Example() {
return (
<div className="flex min-w-0 items-center gap-2">
<TabStrip aria-label="Open documents">
<TabStripItem icon={House} label="Home" active />
<TabStripItem icon={Kanban} label="Q4 roadmap" onClose={() => close("roadmap")} />
</TabStrip>
<TabStripAdd onClick={() => open()} />
</div>
)
}Anatomy#
import { TabStrip, TabStripAdd, TabStripItem } from "@/components/ui/tab-strip"
<div className="flex items-center">
<TabStrip aria-label="…">
<TabStripItem label="…" />
</TabStrip>
<TabStripAdd />
</div>States#
The active tab lifts off the bar and always shows its close button; the others show it on hover, in a slot that is always reserved, so nothing shifts. Leave out onClose for a tab that must stay open (no button, no middle click, no Delete). dragging is the look a sortable library asks for while a tab follows the pointer.
Overflow#
More tabs than room: the strip scrolls sideways and never wraps. Its scrollbar is hidden, so a vertical wheel scrolls it too, and the active tab is scrolled into view whenever it changes or a tab opens. Long titles stop at 15rem and truncate. Give the strip a row that can shrink (min-w-0) and it scrolls once that row runs out of space, with TabStripAdd kept right after the last tab.
Keyboard#
The strip is one stop in the Tab order: the active tab. Moving along it only moves focus; selecting is a separate key, because selecting a document usually navigates.
| Key | Action |
|---|---|
| ← → | Move focus to the previous or next tab, wrapping at the ends. |
| Home End | Move focus to the first or last tab. |
| Enter Space | Select the focused tab (fires its onClick). |
| Delete Backspace | Close the focused tab, if it has onClose. Focus moves to the tab that takes its place. |
Reordering#
The strip doesn't own the order, so reordering is your drag-and-drop library's job. Items take ref and spread every other prop onto their root, which is all dnd-kit's useSortable needs. Three of the sortable's attributes would break the tab pattern, so the tab ignores them: its role="button" (the tab stays role="tab", the only thing a tablist may hold), its tabIndex={0} (only the active tab is a tab stop, so Tab still enters and leaves the strip in one step and the arrow keys rove the rest) and its aria-pressed (a tab reports aria-selected). The rest pass through: aria-roledescription and aria-describedby still tell a screen reader the tab can be picked up. Put DndContext outside the strip, so its screen-reader announcements don't land inside the tablist, and keep a few pixels of activation distance so a click still selects.
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"
import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"
function SortableTab({ doc, active, onSelect, onClose }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: doc.id })
return (
<TabStripItem
ref={setNodeRef}
icon={doc.icon}
label={doc.title}
active={active}
dragging={isDragging}
onClick={onSelect}
onClose={onClose}
style={{ transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined, transition }}
{...attributes}
{...listeners}
/>
)
}
export function SortableStrip({ docs, active, setActive, close, move }) {
// 5px before a press becomes a drag: a click without moving still selects.
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active: from, over }) => over && from.id !== over.id && move(from.id, over.id)}
>
<SortableContext items={docs.map((d) => d.id)} strategy={horizontalListSortingStrategy}>
<TabStrip aria-label="Open documents">
{docs.map((doc) => (
<SortableTab
key={doc.id}
doc={doc}
active={doc.id === active}
onSelect={() => setActive(doc.id)}
onClose={() => close(doc.id)}
/>
))}
</TabStrip>
</SortableContext>
</DndContext>
)
}API reference#
TabStrip
A root <div> around the scrolling role="tablist". Every div prop lands on the root (className merged last), except the two below. Children are the tabs; the active one is always scrolled into view.
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | - | Names the tablist, not the root. |
| aria-labelledby | string | - | Names the tablist from an element on the page. |
| Data attribute | Description |
|---|---|
| data-slot="tab-strip" | On the root. |
| data-slot="tab-strip-list" | On the scrolling tablist. |
TabStripItem
A role="tab" <div> with aria-selected. Takes ref; every other div prop (onClick, style, a sortable's attributes and listeners) is forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | React.ReactNode | - | The tab's text. Truncates. |
| icon | React.ElementType | - | A leading glyph: the Phosphor component, not an element (icon={House}). Rendered at 14px, bold. |
| active | boolean | false | The selected tab: lifted, its close button always visible, and the strip's one tab stop. |
| dragging | boolean | false | Being dragged by a sortable library: lifted above its neighbours, with no transition. |
| onClose | () => void | - | Makes the tab closable: shows the close button, and closes on a middle click and on Delete or Backspace while focused. |
| closeLabel | string | "Close tab" | The close button's hint on hover. |
| static | boolean | false | Drops the press scale. |
| role | string | - | Ignored: the tab is always role="tab". |
| tabIndex | number | - | Ignored: only the active tab is a tab stop, the arrow keys rove the rest. |
| aria-pressed | boolean | "true" | "false" | "mixed" | - | Ignored: a tab reports aria-selected instead. |
| Data attribute | Description |
|---|---|
| data-state | active or inactive. |
| data-active | true or false. |
| data-dragging | Present while dragging. |
| data-slot="tab-strip-close" | The close button, when onClose is set. |
TabStripAdd
The new-tab <button>. Place it beside the strip: a tablist may only hold tabs. Every button prop is forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | "New tab" | The button's accessible name. |
| children | React.ReactNode | - | Replaces the plus glyph. |
| static | boolean | false | Drops the press scale. |
tabStripVariants
The tv recipe behind every part, for styling a part of your own to match.