shadimage
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 · medium
animationmotion

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))
}

More from easeui

Browse all components in this library →