Sora UI

Expandable Tabs

A pill-shaped tab bar that expands into a floating panel, with Motion driving the shell, label, and content transitions.

Made by Axyl

Pill tab bar expanding into a floating focus, progression, ideas, activity, and profile panel.

Loading...

Installation

File Structure

expandable-tabs.tsx

Usage

import { Info, Settings } from 'lucide-react';
import { ExpandableTabs, type ExpandableTab } from '@/components/sora-ui/disclosure/expandable-tabs';

const tabs: ExpandableTab[] = [
  {
    id: 'about',
    label: 'About',
    icon: Info,
    content: <p className="px-1 text-sm">A pill toolbar that expands into a panel.</p>,
  },
  {
    id: 'settings',
    label: 'Settings',
    icon: Settings,
    content: <p className="px-1 text-sm">Whatever content you pass is rendered here.</p>,
  },
];

export default function Page() {
  return <ExpandableTabs tabs={tabs} />;
}

Tune the shared spring and label reveal timing with props:

<ExpandableTabs
  tabs={tabs}
  springDuration={0.45}
  springBounce={0}
  labelDelay={0.08}
  tapScale={0.8}
/>

Control the open tab yourself:

const [selectedId, setSelectedId] = useState<string | null>(null);

<ExpandableTabs
  tabs={tabs}
  selectedId={selectedId}
  onSelectedIdChange={setSelectedId}
/>

Props

PropTypeDefault
tabs?
ExpandableTab[]
-
selectedId?
string | null
-
defaultSelectedId?
string | null
null
onSelectedIdChange?
(id: string | null) => void
-
springDuration?
number
0.45
springBounce?
number
0
labelDelay?
number
0.08
tapScale?
number
0.8
maxContentWidth?
string
"24rem"
className?
string
-
classNames?
ExpandableTabsClassNames
-

When to Use

Use Expandable Tabs for compact toolbars or headers where a full nav bar would take up too much space — a focus/progress/ideas/activity/profile cluster, a floating action bar, or a mobile-friendly nav that only needs to reveal detail on demand. Pass any tab set and panel content via the tabs prop.

Avoid it when tab content needs to stay visible alongside other content, or when users need to compare multiple tabs' content at once — use a standard tabbed panel instead.

Accessibility

Each trigger button gets a stable id, aria-expanded, and aria-controls pointing at its panel's id; the open panel has a matching id, role="region", and aria-labelledby pointing back at its trigger — the same disclosure pattern used by Accordion, since Expandable Tabs can have zero panels open at once (unlike classic ARIA tabs, which always keep exactly one panel visible). Only the active tab's panel is mounted at a time, so aria-controls references an id that doesn't exist in the DOM while collapsed — accepted practice for this pattern, since aria-expanded="false" already communicates the state.

Pressing Escape while a panel is open closes it and returns focus to its trigger button. Clicking outside the root also closes the open panel.

useReducedMotion() collapses the shell, pill-width, label-reveal, and content slide/fade transitions to instant when prefers-reduced-motion: reduce is set.

Credits

Inspired by beui.dev's Expandable Tabs. Reimplemented for Motion and React.

Built by Axyl. A motion-first component registry for React.

Last updated: 7/10/2026