Elastic Tabs

A tab group whose pill stretches to cover both tabs before it contracts onto the one you picked.

Loading preview

Installation

npx shadcn@latest add @loomui/elastic-tabs

Usage

import { ElasticTabs } from "@/components/ui/elastic-tabs"
<ElasticTabs
  items={[
    { value: "text", label: "Text" },
    { value: "backgrounds", label: "Backgrounds" },
    { value: "sections", label: "Sections" },
  ]}
  defaultValue="text"
  onValueChange={setCategory}
/>

The stretch

The pill does not slide. On a change it first grows to span both the tab it is leaving and the tab it is going to, then contracts onto the target.

Moving left and width straight to the destination slides a fixed shape across the group, which reads as a marker being dragged. Spanning first reads as one thing letting go of the old tab and pulling onto the new one, which is the whole effect.

<ElasticTabs duration={420} />

duration is the whole travel. The two halves are cut from it. The stretch holds for the first 45%, and each phase transitions over a little more than half, so they overlap instead of landing in two visible steps.

Positions come from offsetLeft and offsetWidth, re-read by a ResizeObserver on the group, so a row that wraps or a label that changes never leaves the pill behind.

Controlled

const [value, setValue] = React.useState("text")
 
<ElasticTabs items={items} value={value} onValueChange={setValue} />

Leave value out and the group owns the selection. defaultValue sets where it starts, falling back to the first item.

Under prefers-reduced-motion the pill is placed without travelling, so the selection still moves and nothing slides.

Props

PropTypeDefaultDescription
items{ value, label }[]requiredThe tabs, in order.
valuestringnoneSelected tab.
defaultValuestringfirst itemStarting tab when uncontrolled.
onValueChange(value: string) => voidnoneCalled with the value moved to.
durationnumber420Length of the whole travel in milliseconds.
classNamestringnoneMerged onto the group.

Any other <div> prop is forwarded.

Accessibility

A real role="tablist" of role="tab" buttons with aria-selected. Only the selected tab is in the tab order and the arrows move between them, with selection carrying focus, the roving pattern a tablist is meant to use. The pill is aria-hidden, so nothing about it is announced.