Expandable Tabs
A row of icon tabs where the selected one expands to reveal its label, sliding a shared background pill to match.
"use client";
import { Bell, Home, Search, Settings, User } from "lucide-react";
import { useState } from "react";
import { ExpandableTabs, type ExpandableTab } from "@/components/motion/expandable-tabs";
const TABS: ExpandableTab[] = [
{ id: "home", label: "Home", icon: <Home className="h-4 w-4" /> },
{ id: "search", label: "Search", icon: <Search className="h-4 w-4" /> },
{ id: "alerts", label: "Alerts", icon: <Bell className="h-4 w-4" /> },
{ id: "profile", label: "Profile", icon: <User className="h-4 w-4" /> },
{ id: "settings", label: "Settings", icon: <Settings className="h-4 w-4" /> },
];
export function ExpandableTabsPreview() {
const [value, setValue] = useState("home");
return <ExpandableTabs tabs={TABS} value={value} onChange={setValue} />;
}
"use client";
// easeui.dev/components/motion/expandable-tabs
import { motion } from "motion/react";
import { type KeyboardEvent, type ReactNode, useState } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ExpandableTab {
id: string;
label: string;
icon: ReactNode;
}
export interface ExpandableTabsProps {
tabs: ExpandableTab[];
/** Controlled selected tab id. */
value?: string;
/** Starting selection when uncontrolled. Defaults to the first tab. */
defaultValue?: string;
onChange?: (id: string) => void;
className?: string;
}
const NAV_KEYS = new Set(["ArrowRight", "ArrowLeft", "Home", "End"]);
/**
* A row of icon tabs where the selected one expands to reveal its label,
* sliding a shared background pill to match.
*/
export function ExpandableTabs({ tabs, value, defaultValue, onChange, className }: ExpandableTabsProps) {
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? tabs[0]?.id);
const isControlled = value !== undefined;
const current = isControlled ? value : uncontrolled;
const select = (id: string) => {
if (!isControlled) setUncontrolled(id);
onChange?.(id);
};
// Arrow keys, Home, and End move between tabs and select them.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!NAV_KEYS.has(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (index < 0) return;
event.preventDefault();
const next =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
buttons[next].focus();
buttons[next].click();
};
return (
<div
role="tablist"
onKeyDown={onKeyDown}
className={cn(
"inline-flex items-center gap-1 rounded-full bg-card p-1 shadow-[0_0_0_1px_var(--border)]",
className,
)}
>
{tabs.map((tab) => {
const active = tab.id === current;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => select(tab.id)}
className="relative flex h-9 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-foreground/40"
>
{active ? (
<motion.span
layoutId="expandable-tabs-indicator"
transition={SPRING_LAYOUT}
className="absolute inset-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]"
/>
) : null}
<span
className={cn(
"relative z-10 flex shrink-0 items-center",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{tab.icon}
</span>
<motion.span
initial={false}
animate={{ width: active ? "auto" : 0, opacity: active ? 1 : 0 }}
transition={SPRING_LAYOUT}
className="relative z-10 overflow-hidden whitespace-nowrap text-foreground"
>
{tab.label}
</motion.span>
</button>
);
})}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/expandable-tabs
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/expandable-tabs.tsx "use client"; // easeui.dev/components/motion/expandable-tabs import { motion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useState } from "react"; import { SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface ExpandableTab { id: string; label: string; icon: ReactNode; } export interface ExpandableTabsProps { tabs: ExpandableTab[]; /** Controlled selected tab id. */ value?: string; /** Starting selection when uncontrolled. Defaults to the first tab. */ defaultValue?: string; onChange?: (id: string) => void; className?: string; } const NAV_KEYS = new Set(["ArrowRight", "ArrowLeft", "Home", "End"]); /** * A row of icon tabs where the selected one expands to reveal its label, * sliding a shared background pill to match. */ export function ExpandableTabs({ tabs, value, defaultValue, onChange, className }: ExpandableTabsProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue ?? tabs[0]?.id); const isControlled = value !== undefined; const current = isControlled ? value : uncontrolled; const select = (id: string) => { if (!isControlled) setUncontrolled(id); onChange?.(id); }; // Arrow keys, Home, and End move between tabs and select them. const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (!NAV_KEYS.has(event.key)) return; const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length; buttons[next].focus(); buttons[next].click(); }; return ( <div role="tablist" onKeyDown={onKeyDown} className={cn( "inline-flex items-center gap-1 rounded-full bg-card p-1 shadow-[0_0_0_1px_var(--border)]", className, )} > {tabs.map((tab) => { const active = tab.id === current; return ( <button key={tab.id} type="button" role="tab" aria-selected={active} tabIndex={active ? 0 : -1} onClick={() => select(tab.id)} className="relative flex h-9 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-foreground/40" > {active ? ( <motion.span layoutId="expandable-tabs-indicator" transition={SPRING_LAYOUT} className="absolute inset-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]" /> ) : null} <span className={cn( "relative z-10 flex shrink-0 items-center", active ? "text-foreground" : "text-muted-foreground", )} > {tab.icon} </span> <motion.span initial={false} animate={{ width: active ? "auto" : 0, opacity: active ? 1 : 0 }} transition={SPRING_LAYOUT} className="relative z-10 overflow-hidden whitespace-nowrap text-foreground" > {tab.label} </motion.span> </button> ); })} </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | {} | - | - |
| value? | string | - | Controlled selected tab id. |
| defaultValue? | string | - | Starting selection when uncontrolled. Defaults to the first tab. |
| onChange? | ((id: string) => void) | - | - |
| className? | string | - | - |
Updated