Navigation

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.en

New project or installation trouble? Start here →

Expandable 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.

Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
tabsExpandableTabItem[]—The tab list; you can mix in { type: "separator" } separators
selectednumber | null—Controlled selected index; null means everything is collapsed
defaultSelectednumber | nullnullInitial selected index in uncontrolled mode
onChange(index: number | null) => void—Fires when the selection changes
collapsiblebooleantrueWhether clicking the currently selected tab again collapses it
deselectOnOutsideClickbooleantrueWhether clicking outside the component collapses it
labelstring—Accessible group label (role="toolbar")
activeClassNamestring—Accent color class for the selected tab (text / icon)
classNamestring—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 to size-5
  • Only one tab can be selected at a time, and null collapses everything back to plain icons

Accessibility

  • The container is role="toolbar", and label supplies its aria-label
  • Every tab has an aria-label and aria-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.