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.

Q4 roadmap

12 initiatives across 3 teams.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add tab-strip

Usage#

usage.tsxTSX
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.

KeyAction
← →Move focus to the previous or next tab, wrapping at the ends.
Home EndMove focus to the first or last tab.
Enter SpaceSelect the focused tab (fires its onClick).
Delete BackspaceClose 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.

sortable-tabs.tsxTSX
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.

PropTypeDefaultDescription
aria-labelstring-Names the tablist, not the root.
aria-labelledbystring-Names the tablist from an element on the page.
Data attributeDescription
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.

PropTypeDefaultDescription
label*React.ReactNode-The tab's text. Truncates.
iconReact.ElementType-A leading glyph: the Phosphor component, not an element (icon={House}). Rendered at 14px, bold.
activebooleanfalseThe selected tab: lifted, its close button always visible, and the strip's one tab stop.
draggingbooleanfalseBeing 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.
closeLabelstring"Close tab"The close button's hint on hover.
staticbooleanfalseDrops the press scale.
rolestring-Ignored: the tab is always role="tab".
tabIndexnumber-Ignored: only the active tab is a tab stop, the arrow keys rove the rest.
aria-pressedboolean | "true" | "false" | "mixed"-Ignored: a tab reports aria-selected instead.
Data attributeDescription
data-stateactive or inactive.
data-activetrue or false.
data-draggingPresent 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.

PropTypeDefaultDescription
aria-labelstring"New tab"The button's accessible name.
childrenReact.ReactNode-Replaces the plus glyph.
staticbooleanfalseDrops the press scale.

tabStripVariants

The tv recipe behind every part, for styling a part of your own to match.

FAQ#

Tabs switches between panels of one view, and every tab is always there. Tab Strip lists documents the user opens, closes and reorders, and the page itself is rendered by your router, not by the strip.