Expandable Tabs
Icon-based tab navigation where the selected icon smoothly expands into text, with separator support and controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add expandable-tabs.enExpandable Tabs is a React expandable tabs (icon tab bar) component built with Tailwind CSS and Motion. Tabs show only their icon until selected, and the selected one expands with a spring animation to reveal its title, which suits toolbars and navigation bars with little room.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<ExpandableTabs/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- A bottom navigation bar on a mobile site or PWA, where there are many buttons and little width.
- A floating toolbar in an editor or whiteboard tool, with separators grouping the tools.
- View switching at the top of a dashboard, such as Overview, Reports, and Settings.
- Quick actions in the side of a chat or collaboration app, showing names only when selected.
Installation
npx webberui@latest add https://webberui.com/r/expandable-tabs.en.jsonOr, once registries are configured in components.json, install it as @webberui/expandable-tabs.
Usage
import { Bell, Home, Settings, User } from "lucide-react";
import {
ExpandableTabs,
type ExpandableTabItem,
} from "@/components/ui/expandable-tabs";
const tabs: ExpandableTabItem[] = [
{ title: "Home", icon: <Home /> },
{ title: "Alerts", icon: <Bell /> },
{ type: "separator" },
{ title: "Settings", icon: <Settings /> },
{ title: "Profile", icon: <User /> },
];
export function Nav() {
return <ExpandableTabs tabs={tabs} label="Main navigation" />;
}controlled mode
Passing selected and onChange puts it in controlled mode, so it can stay in sync with other state:
const [selected, setSelected] = React.useState<number | null>(0);
<ExpandableTabs tabs={tabs} selected={selected} onChange={setSelected} />;Examples
Route switching with the Next.js App Router
Derive the current index from usePathname() and pass it to selected, then call router.push in onChange. collapsible={false} and deselectOnOutsideClick={false} keep the current page's tab expanded.
"use client";
import { usePathname, useRouter } from "next/navigation";
import { BarChart3, Home, Settings } from "lucide-react";
import { ExpandableTabs, type ExpandableTabItem } from "@/components/ui/expandable-tabs";
const routes = ["/dashboard", "/dashboard/reports", "/dashboard/settings"];
const tabs: ExpandableTabItem[] = [
{ title: "Overview", icon: <Home /> },
{ title: "Reports", icon: <BarChart3 /> },
{ title: "Settings", icon: <Settings /> },
];
export function DashboardNav() {
const pathname = usePathname();
const router = useRouter();
const index = routes.indexOf(pathname);
return (
<ExpandableTabs
tabs={tabs}
label="Dashboard navigation"
selected={index === -1 ? null : index}
onChange={(i) => i !== null && router.push(routes[i])}
collapsible={false}
deselectOnOutsideClick={false}
/>
);
}Indexes when separators are present
onChange receives the index into the tabs array, and separators take up a slot. In this example "Delete" is index 3, not 2.
const tabs: ExpandableTabItem[] = [
{ title: "Copy", icon: <Copy /> },
{ title: "Share", icon: <Share2 /> },
{ type: "separator" },
{ title: "Delete", icon: <Trash2 /> },
];
<ExpandableTabs
tabs={tabs}
label="File actions"
onChange={(i) => {
if (i === 3) openDeleteDialog();
}}
/>Custom active color
activeClassName sets the text and icon color of the selected tab, and className overrides the container's styles.
<ExpandableTabs
tabs={tabs}
defaultSelected={0}
activeClassName="text-blue-600 dark:text-blue-400"
className="rounded-full shadow-none"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | ExpandableTabItem[] | — | The tab list; you can mix in { type: "separator" } separators |
selected | number | null | — | Controlled selected index; null means everything is collapsed |
defaultSelected | number | null | null | Initial selected index in uncontrolled mode |
onChange | (index: number | null) => void | — | Fires when the selection changes |
collapsible | boolean | true | Whether clicking the currently selected tab again collapses it |
deselectOnOutsideClick | boolean | true | Whether clicking outside the component collapses it |
label | string | — | Accessible group label (role="toolbar") |
activeClassName | string | — | Accent color class for the selected tab (text / icon) |
className | string | — | Appended to the container's className |
ExpandableTabItem is either { title: string; icon: React.ReactNode } or { type: "separator" }.
How it works
- The selected tab expands the container padding and the title width together with a spring transition, morphing back in reverse when it collapses
- Icons are passed in as
React.ReactNode, so any icon library works; SVGs are automatically constrained tosize-5 - Only one tab can be selected at a time, and
nullcollapses everything back to plain icons
Accessibility
- The container is
role="toolbar", andlabelsupplies itsaria-label - Every tab has an
aria-labelandaria-pressed, and keeps its label even when the text is expanded - The arrow keys (←/→/↑/↓) plus Home/End move focus between tabs (roving tabindex), skipping separators automatically
- When the user has "reduce motion" enabled at the system level, expanding and collapsing become instant switches with no displacement animation
FAQ
Does it work with the Next.js App Router?
Yes. The source already has "use client", so import it from a Server Component page directly; to switch routes in onChange, wrap it in your own client component as in the example above.
How do I stop the selected tab from collapsing?
By default, clicking the selected tab again or clicking outside the component collapses it. Set collapsible={false} and deselectOnOutsideClick={false} to keep the selection.
Is it keyboard accessible?
Yes. The tabs use a roving tabindex, so Tab stops on only one of them; arrow keys and Home / End move focus between tabs and skip separators, and Enter or Space selects. Every tab has an aria-label and aria-pressed.
Can I use icons other than lucide-react?
Yes. icon accepts any React.ReactNode, and SVGs inside it are sized to size-5.
Is this an ARIA tablist?
No. The container is role="toolbar" and each button reports its state with aria-pressed; the component has no tab panels, so render the matching content yourself based on the index from onChange.
Dynamic Island Nav
A Dynamic Island-style pill navigation — the selected pill slides and morphs between items, the selected item's name expands from 0, and one more click morphs the island downward into a panel.
Gooey Menu
A liquid, gooey expanding menu — click the floating trigger and the items are "pulled" out of the button through an SVG goo filter and fuse together, with four directions plus radial fanning.