easeui
componentexpandable-tabssource: ok · refreshed
Expandable Tabs
A row of icon tabs where the selected one expands to reveal its label, sliding a shared background pill to match.
npx shadcn add "https://easeui.dev/r/expandable-tabs.json"free
Interaction
scroll-revealdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files3 · source cached 10/8/2026, 9:27:06 AM
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))
}