Motion UIComponentsComponent

Smooth tabs

Tabs whose active pill slides between labels while panels crossfade with a directional blur.

npx shadcn@latest add @motion/smooth-tabs

API reference

Components

<SmoothTabs />

The tabs root: Base UI's Tabs.Root (selection semantics) held controlled so the slide direction can be computed before each change commits, plus the shared pill and the theme-timed transitions, provided through context. Renders a single wrapper element; lay its two children (a SmoothTabsList and a SmoothTabsPanels) out via className.

defaultValue?

string = ""

The uncontrolled initial selected tab value. One of the child SmoothTabsTab/SmoothTabsPanel values. Ignored if value is passed.

value?

string

The selected tab value (controlled). Pair with onValueChange.

onValueChange?

(value: string) => void

Called with the newly selected tab's value whenever the selection changes (click or keyboard). Required for controlled use.

contentOffsetX?

number = 50

Horizontal distance (px) the outgoing/incoming panel slides during the crossfade. The one genuine content-effect constant; timing (the indicator spring, the crossfade duration) is theme-owned and is not a prop.

className?

string

Merged onto the root wrapper - lay the tablist and panels out here (a flex flex-col gap-4 stack, a max-width).

children?

ReactNode

SmoothTabsList and SmoothTabsPanels.

<SmoothTabsList />

The tablist rail: Base UI's Tabs.List (roving-tabindex keyboard navigation; activateOnFocus makes selection follow focus - automatic activation) dressed as a recessed bg-muted well.

ariaLabel?

string

Accessible name for the role="tablist" (e.g. "Workspace views"). Strongly recommended - a tablist with no label is opaque to screen-reader users.

className?

string

Merged onto the tablist shell - extend the default recessed bg-muted rail here.

children?

ReactNode

The SmoothTabsTab children.

<SmoothTabsTab />

One tab: Base UI's Tabs.Tab (a real role="tab" button with the aria-selected/aria-controls wiring and roving tabindex) timed by the theme's snap colour transition, carrying the shared sliding pill when it is selected. Must be rendered inside a SmoothTabsList.

value

string

This tab's value. When it matches the group's selected value the tab is active and carries the sliding pill. Must match a SmoothTabsPanel.

children?

ReactNode

The tab's label (and any inline content).

className?

string

Merged onto the tab <button>.

<SmoothTabsPanels />

The crossfade viewport: clips the horizontally-sliding panels (overflow-hidden) and drives a directional AnimatePresence mode="sync" swap stacked in one CSS grid cell. Reads its SmoothTabsPanel children as config, and renders the single active one as a real Base UI Tabs.Panel (role/aria wired by the primitive) rendered as the animated motion.div. keepMounted + [&[hidden]]:block keep the outgoing panel visible while its exit animation runs.

children?

ReactNode

The SmoothTabsPanel children. Only the one matching the selected value is shown; enter/exit run together in a shared grid cell so the card height holds when panels are the same size.

className?

string

Merged onto the crossfade viewport - give it its card surface, border and a min-h-* so a taller panel does not leave a short one looking collapsed.

<SmoothTabsPanel />

A declarative panel: it holds a value and its content, and renders nothing itself. Its parent SmoothTabsPanels reads these props and renders the single active panel inside the animated role="tabpanel" wrapper, so the crossfade is one shared element across every tab. Must be a child of SmoothTabsPanels.

value

string

This panel's value. It is shown when it matches the group's selected value; must match a SmoothTabsTab.

children?

ReactNode

The panel's content.

className?

string

Merged onto the animated role="tabpanel" wrapper SmoothTabsPanels renders for the active panel (its padding, its inner layout).

Hooks

useSmoothTabsContext

Public hook: read the active tab value and a selector, for a consumer that wants to drive or reflect the tabs from outside a part (a "next" button, a breadcrumb that mirrors the active view). Must be called inside a <SmoothTabs>.

useSmoothTabsContext(): {
  /** The currently selected tab's value. */
  value: string
  /** Select a tab by value (drives the same slide + direction a click does). */
  select: (value: string) => void
}

Related examples