Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Preview
TSXcomponents/previews/blocks/expandable-tabs.preview.tsx
"use client";
import {
BadgeCheck,
Brush,
CalendarClock,
ChartSpline,
ChevronRight,
ClipboardCheck,
CloudUpload,
FileText,
Gauge,
GitBranch,
Images,
Inbox,
type LucideIcon,
Megaphone,
MessageCircle,
PackageOpen,
RefreshCw,
Rocket,
Siren,
SwatchBook,
UploadCloud,
Users,
Webhook,
Workflow,
} from "lucide-react";
import { ExpandableTabs } from "@/components/motion/expandable-tabs";
function Row({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
return (
<button
type="button"
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">{label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
);
}
function Menu({ rows }: { rows: { icon: LucideIcon; label: string }[] }) {
return (
<div className="flex w-[17.125rem] flex-col gap-0.5">
{rows.map((r) => (
<Row key={r.label} icon={r.icon} label={r.label} />
))}
</div>
);
}
export function ExpandableTabsPreview() {
return (
<div className="flex min-h-88 w-full items-end justify-center">
<ExpandableTabs
items={[
{
id: "launch",
label: "Launch",
icon: <Rocket className="h-4 w-4" />,
content: (
<Menu
rows={[
{ icon: FileText, label: "Release Brief" },
{ icon: ClipboardCheck, label: "Launch Checklist" },
{ icon: Megaphone, label: "Campaign Notes" },
{ icon: CalendarClock, label: "Rollout Calendar" },
{ icon: CloudUpload, label: "Ship Build" },
]}
/>
),
},
{
id: "inbox",
label: "Inbox",
icon: <Inbox className="h-4 w-4" />,
content: (
<Menu
rows={[
{ icon: MessageCircle, label: "Client Feedback" },
{ icon: Users, label: "Team Requests" },
{ icon: BadgeCheck, label: "Approval Notes" },
]}
/>
),
},
{
id: "flows",
label: "Flows",
icon: <Workflow className="h-4 w-4" />,
content: (
<Menu
rows={[
{ icon: GitBranch, label: "Trigger Map" },
{ icon: Webhook, label: "Webhook Runs" },
{ icon: RefreshCw, label: "Retry Queue" },
]}
/>
),
},
{
id: "assets",
label: "Assets",
icon: <PackageOpen className="h-4 w-4" />,
content: (
<Menu
rows={[
{ icon: SwatchBook, label: "Brand Kit" },
{ icon: Images, label: "Mockup Library" },
{ icon: Brush, label: "Design Tokens" },
{ icon: UploadCloud, label: "Export Queue" },
]}
/>
),
},
{
id: "status",
label: "Status",
icon: <ChartSpline className="h-4 w-4" />,
content: (
<Menu
rows={[
{ icon: Gauge, label: "Activation" },
{ icon: ChartSpline, label: "Conversion" },
{ icon: Siren, label: "Incidents" },
]}
/>
),
},
]}
/>
</div>
);
}
TSXcomponents/motion/expandable-tabs.tsx
"use client";
// beui.dev/components/blocks/expandable-tabs
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ExpandableTabsItem = {
id: string;
/** String label — shown inside the active tab and used as the button's accessible name. */
label: string;
icon: ReactNode;
/** Panel shown above the bar when this tab is active. */
content: ReactNode;
};
export type ExpandableTabsClassNames = {
root?: string;
panel?: string;
bar?: string;
tab?: string;
activeTab?: string;
icon?: string;
label?: string;
pill?: string;
};
export interface ExpandableTabsProps {
items: ExpandableTabsItem[];
/** Active tab id, or null/undefined for the closed (bar-only) state. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (id: string | null) => void;
className?: string;
classNames?: ExpandableTabsClassNames;
}
type Size = { width: number; height: number };
// DynamicIsland-style real width/height motion, tuned tighter here so the tab
// bar feels controlled instead of elastic.
const SHELL_SPRING = { type: "spring", duration: 0.58, bounce: 0.06 } as const;
// Position-only tab layout motion keeps switching loose without stretching
// icons or letting the label linger.
const TAB_CHANGE_SPRING = {
type: "spring",
duration: 0.46,
bounce: 0.04,
} as const;
const LABEL_OPEN = { type: "spring", duration: 0.38, bounce: 0.03 } as const;
const LABEL_CLOSE = { duration: 0.16, ease: EASE_OUT } as const;
// Fixed bar height keeps the content panel's bottom reserve static so the open
// height is right on the first frame. p-2 (16) + h-9 button (36).
const BAR_H = 52;
const TAB_W = 32;
const BAR_X = 16;
const BAR_GAP = 4;
const ROOT_BORDER = 2;
const ICON_W = 16;
const ACTIVE_LEFT_PAD = 10;
const ACTIVE_RIGHT_PAD = 16;
const LABEL_GAP = 7;
const PANEL_DOCK_GAP = 4;
// Content is clipped above the dock so rows never pass through the icon bar.
// It enters from slightly above instead of from the dock line.
const CONTENT_VARIANTS: Variants = {
enter: { y: -8, scale: 0.98, opacity: 0, filter: "blur(4px)" },
center: { y: 0, scale: 1, opacity: 1, filter: "blur(0px)" },
exit: {
y: -6,
scale: 0.98,
opacity: 0,
filter: "blur(4px)",
transition: { duration: 0.08, ease: EASE_OUT },
},
};
const REDUCED_CONTENT_VARIANTS: Variants = {
enter: { opacity: 0, filter: "blur(0px)" },
center: { opacity: 1, filter: "blur(0px)" },
exit: {
opacity: 0,
filter: "blur(0px)",
transition: { duration: 0.08, ease: EASE_OUT },
},
};
const CONTENT_SPRING = { type: "spring", duration: 0.46, bounce: 0.08 } as const;
function sameSize(a: Size | null | undefined, b: Size | null | undefined) {
return a?.width === b?.width && a?.height === b?.height;
}
function sameWidths(a: Record<string, number>, b: Record<string, number>) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
return aKeys.every((key) => a[key] === b[key]);
}
function useContentSize() {
const ref = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<Size | null>(null);
const measure = useCallback(() => {
const el = ref.current;
if (!el) return;
const next = { width: el.offsetWidth, height: el.offsetHeight };
setSize((current) => (sameSize(current, next) ? current : next));
}, []);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
return [ref, size] as const;
}
function useLabelWidths(items: ExpandableTabsItem[]) {
const refs = useRef<Record<string, HTMLSpanElement | null>>({});
const [widths, setWidths] = useState<Record<string, number>>({});
const setLabelMeasureRef = useCallback(
(id: string) => (node: HTMLSpanElement | null) => {
refs.current[id] = node;
},
[],
);
const measure = useCallback(() => {
const next: Record<string, number> = {};
for (const item of items) {
const node = refs.current[item.id];
if (node) {
next[item.id] = Math.ceil(node.offsetWidth);
}
}
setWidths((current) => (sameWidths(current, next) ? current : next));
}, [items]);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(measure);
for (const item of items) {
const node = refs.current[item.id];
if (node) {
observer.observe(node);
}
}
return () => observer.disconnect();
}, [items, measure]);
return { setLabelMeasureRef, widths };
}
export function ExpandableTabs({
items,
value,
defaultValue = null,
onValueChange,
className,
classNames,
}: ExpandableTabsProps) {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const [sizerRef, size] = useContentSize();
const { setLabelMeasureRef, widths: labelWidths } = useLabelWidths(items);
const controlled = value !== undefined;
const [internal, setInternal] = useState<string | null>(defaultValue);
const activeId = controlled ? value : internal;
const active = items.find((item) => item.id === activeId) ?? null;
const visualActiveId = active?.id ?? null;
const setActive = useCallback(
(next: string | null) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange],
);
// Outside click / Escape closes — it behaves like an open menu.
useEffect(() => {
if (!visualActiveId) return;
const onPointer = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setActive(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(null);
};
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [setActive, visualActiveId]);
const closedSize = {
width:
items.length * TAB_W +
Math.max(0, items.length - 1) * BAR_GAP +
BAR_X +
ROOT_BORDER,
height: BAR_H + ROOT_BORDER,
};
const openSize = size
? {
width: Math.max(size.width + ROOT_BORDER, closedSize.width),
height: Math.max(size.height + ROOT_BORDER, closedSize.height),
}
: closedSize;
const targetSize = active ? openSize : closedSize;
const getActiveTabWidth = useCallback(
(item: ExpandableTabsItem) =>
Math.max(
TAB_W,
ACTIVE_LEFT_PAD +
ICON_W +
LABEL_GAP +
(labelWidths[item.id] ?? 0) +
ACTIVE_RIGHT_PAD,
),
[labelWidths],
);
return (
<>
<motion.div
ref={rootRef}
initial={false}
animate={
targetSize
? { width: targetSize.width, height: targetSize.height }
: undefined
}
transition={reduce ? { duration: 0 } : SHELL_SPRING}
style={{ transformOrigin: "bottom center" }}
className={cn(
"relative overflow-hidden rounded-[26px] border border-border bg-card",
className,
classNames?.root,
)}
>
<div
ref={sizerRef}
aria-hidden
className={cn(
"pointer-events-none invisible absolute left-0 top-0 grid w-max px-2 pt-2",
classNames?.panel,
)}
style={{ paddingBottom: BAR_H + PANEL_DOCK_GAP }}
>
{items.map((item) => (
<div key={item.id} className="col-start-1 row-start-1 w-max">
{item.content}
</div>
))}
</div>
<div
className={cn(
"absolute left-0 right-0 top-0 z-10 overflow-hidden px-2 pt-2",
classNames?.panel,
)}
style={{ bottom: BAR_H + PANEL_DOCK_GAP }}
>
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<motion.div
key={active.id}
variants={reduce ? REDUCED_CONTENT_VARIANTS : CONTENT_VARIANTS}
initial="enter"
animate="center"
exit="exit"
transition={
reduce ? { duration: 0.15, ease: EASE_OUT } : CONTENT_SPRING
}
className="w-max"
style={{
transformOrigin: "top center",
willChange: "transform, opacity, filter",
}}
>
{active.content}
</motion.div>
) : null}
</AnimatePresence>
</div>
<div
role="tablist"
aria-label="Navigation tabs"
aria-orientation="horizontal"
className={cn(
"absolute bottom-0 left-0 z-20 flex w-full items-center justify-between gap-1 p-2",
classNames?.bar,
)}
style={{ height: BAR_H }}
>
{items.map((item) => {
const isActive = item.id === visualActiveId;
const activeTabWidth = getActiveTabWidth(item);
const labelWidth = labelWidths[item.id] ?? 0;
return (
<motion.button
key={item.id}
type="button"
role="tab"
aria-selected={isActive}
aria-label={item.label}
onClick={() => setActive(isActive ? null : item.id)}
layout={reduce ? false : "position"}
animate={{
width: active && isActive ? activeTabWidth : TAB_W,
}}
transition={reduce ? { duration: 0 } : TAB_CHANGE_SPRING}
className={cn(
"relative isolate flex h-9 min-w-8 shrink-0 items-center justify-center overflow-hidden rounded-[18px] px-2 text-sm font-medium outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
active && isActive && "min-w-0 justify-start pl-2.5 pr-4",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
classNames?.tab,
isActive && classNames?.activeTab,
)}
>
{isActive ? (
<span
className={cn(
"absolute inset-0 -z-10 rounded-[18px] bg-foreground/10",
classNames?.pill,
)}
/>
) : null}
<span
className={cn(
"grid shrink-0 place-items-center",
classNames?.icon,
)}
>
{item.icon}
</span>
<motion.span
aria-hidden
initial={false}
animate={
reduce
? {
width: isActive ? labelWidth : 0,
opacity: isActive ? 1 : 0,
marginLeft: isActive ? LABEL_GAP : 0,
filter: "blur(0px)",
}
: {
width: isActive ? labelWidth : 0,
opacity: isActive ? 1 : 0,
marginLeft: isActive ? LABEL_GAP : 0,
filter: isActive ? "blur(0px)" : "blur(3px)",
}
}
transition={
reduce
? { duration: 0 }
: isActive
? LABEL_OPEN
: LABEL_CLOSE
}
className={cn(
"inline-block overflow-hidden whitespace-nowrap",
classNames?.label,
)}
>
{item.label}
</motion.span>
</motion.button>
);
})}
</div>
</motion.div>
<div
aria-hidden="true"
className="pointer-events-none fixed left-0 top-0 -z-10 flex opacity-0"
>
{items.map((item) => (
<span
className={cn(
"whitespace-nowrap text-sm font-medium leading-none",
classNames?.label,
)}
key={item.id}
ref={setLabelMeasureRef(item.id)}
>
{item.label}
</span>
))}
</div>
</>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/expandable-tabs
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
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.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/expandable-tabs.tsx
"use client";
// beui.dev/components/blocks/expandable-tabs
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ExpandableTabsItem = {
id: string;
/** String label — shown inside the active tab and used as the button's accessible name. */
label: string;
icon: ReactNode;
/** Panel shown above the bar when this tab is active. */
content: ReactNode;
};
export type ExpandableTabsClassNames = {
root?: string;
panel?: string;
bar?: string;
tab?: string;
activeTab?: string;
icon?: string;
label?: string;
pill?: string;
};
export interface ExpandableTabsProps {
items: ExpandableTabsItem[];
/** Active tab id, or null/undefined for the closed (bar-only) state. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (id: string | null) => void;
className?: string;
classNames?: ExpandableTabsClassNames;
}
type Size = { width: number; height: number };
// DynamicIsland-style real width/height motion, tuned tighter here so the tab
// bar feels controlled instead of elastic.
const SHELL_SPRING = { type: "spring", duration: 0.58, bounce: 0.06 } as const;
// Position-only tab layout motion keeps switching loose without stretching
// icons or letting the label linger.
const TAB_CHANGE_SPRING = {
type: "spring",
duration: 0.46,
bounce: 0.04,
} as const;
const LABEL_OPEN = { type: "spring", duration: 0.38, bounce: 0.03 } as const;
const LABEL_CLOSE = { duration: 0.16, ease: EASE_OUT } as const;
// Fixed bar height keeps the content panel's bottom reserve static so the open
// height is right on the first frame. p-2 (16) + h-9 button (36).
const BAR_H = 52;
const TAB_W = 32;
const BAR_X = 16;
const BAR_GAP = 4;
const ROOT_BORDER = 2;
const ICON_W = 16;
const ACTIVE_LEFT_PAD = 10;
const ACTIVE_RIGHT_PAD = 16;
const LABEL_GAP = 7;
const PANEL_DOCK_GAP = 4;
// Content is clipped above the dock so rows never pass through the icon bar.
// It enters from slightly above instead of from the dock line.
const CONTENT_VARIANTS: Variants = {
enter: { y: -8, scale: 0.98, opacity: 0, filter: "blur(4px)" },
center: { y: 0, scale: 1, opacity: 1, filter: "blur(0px)" },
exit: {
y: -6,
scale: 0.98,
opacity: 0,
filter: "blur(4px)",
transition: { duration: 0.08, ease: EASE_OUT },
},
};
const REDUCED_CONTENT_VARIANTS: Variants = {
enter: { opacity: 0, filter: "blur(0px)" },
center: { opacity: 1, filter: "blur(0px)" },
exit: {
opacity: 0,
filter: "blur(0px)",
transition: { duration: 0.08, ease: EASE_OUT },
},
};
const CONTENT_SPRING = { type: "spring", duration: 0.46, bounce: 0.08 } as const;
function sameSize(a: Size | null | undefined, b: Size | null | undefined) {
return a?.width === b?.width && a?.height === b?.height;
}
function sameWidths(a: Record<string, number>, b: Record<string, number>) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
return aKeys.every((key) => a[key] === b[key]);
}
function useContentSize() {
const ref = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<Size | null>(null);
const measure = useCallback(() => {
const el = ref.current;
if (!el) return;
const next = { width: el.offsetWidth, height: el.offsetHeight };
setSize((current) => (sameSize(current, next) ? current : next));
}, []);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
return [ref, size] as const;
}
function useLabelWidths(items: ExpandableTabsItem[]) {
const refs = useRef<Record<string, HTMLSpanElement | null>>({});
const [widths, setWidths] = useState<Record<string, number>>({});
const setLabelMeasureRef = useCallback(
(id: string) => (node: HTMLSpanElement | null) => {
refs.current[id] = node;
},
[],
);
const measure = useCallback(() => {
const next: Record<string, number> = {};
for (const item of items) {
const node = refs.current[item.id];
if (node) {
next[item.id] = Math.ceil(node.offsetWidth);
}
}
setWidths((current) => (sameWidths(current, next) ? current : next));
}, [items]);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(measure);
for (const item of items) {
const node = refs.current[item.id];
if (node) {
observer.observe(node);
}
}
return () => observer.disconnect();
}, [items, measure]);
return { setLabelMeasureRef, widths };
}
export function ExpandableTabs({
items,
value,
defaultValue = null,
onValueChange,
className,
classNames,
}: ExpandableTabsProps) {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const [sizerRef, size] = useContentSize();
const { setLabelMeasureRef, widths: labelWidths } = useLabelWidths(items);
const controlled = value !== undefined;
const [internal, setInternal] = useState<string | null>(defaultValue);
const activeId = controlled ? value : internal;
const active = items.find((item) => item.id === activeId) ?? null;
const visualActiveId = active?.id ?? null;
const setActive = useCallback(
(next: string | null) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange],
);
// Outside click / Escape closes — it behaves like an open menu.
useEffect(() => {
if (!visualActiveId) return;
const onPointer = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setActive(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(null);
};
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [setActive, visualActiveId]);
const closedSize = {
width:
items.length * TAB_W +
Math.max(0, items.length - 1) * BAR_GAP +
BAR_X +
ROOT_BORDER,
height: BAR_H + ROOT_BORDER,
};
const openSize = size
? {
width: Math.max(size.width + ROOT_BORDER, closedSize.width),
height: Math.max(size.height + ROOT_BORDER, closedSize.height),
}
: closedSize;
const targetSize = active ? openSize : closedSize;
const getActiveTabWidth = useCallback(
(item: ExpandableTabsItem) =>
Math.max(
TAB_W,
ACTIVE_LEFT_PAD +
ICON_W +
LABEL_GAP +
(labelWidths[item.id] ?? 0) +
ACTIVE_RIGHT_PAD,
),
[labelWidths],
);
return (
<>
<motion.div
ref={rootRef}
initial={false}
animate={
targetSize
? { width: targetSize.width, height: targetSize.height }
: undefined
}
transition={reduce ? { duration: 0 } : SHELL_SPRING}
style={{ transformOrigin: "bottom center" }}
className={cn(
"relative overflow-hidden rounded-[26px] border border-border bg-card",
className,
classNames?.root,
)}
>
<div
ref={sizerRef}
aria-hidden
className={cn(
"pointer-events-none invisible absolute left-0 top-0 grid w-max px-2 pt-2",
classNames?.panel,
)}
style={{ paddingBottom: BAR_H + PANEL_DOCK_GAP }}
>
{items.map((item) => (
<div key={item.id} className="col-start-1 row-start-1 w-max">
{item.content}
</div>
))}
</div>
<div
className={cn(
"absolute left-0 right-0 top-0 z-10 overflow-hidden px-2 pt-2",
classNames?.panel,
)}
style={{ bottom: BAR_H + PANEL_DOCK_GAP }}
>
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<motion.div
key={active.id}
variants={reduce ? REDUCED_CONTENT_VARIANTS : CONTENT_VARIANTS}
initial="enter"
animate="center"
exit="exit"
transition={
reduce ? { duration: 0.15, ease: EASE_OUT } : CONTENT_SPRING
}
className="w-max"
style={{
transformOrigin: "top center",
willChange: "transform, opacity, filter",
}}
>
{active.content}
</motion.div>
) : null}
</AnimatePresence>
</div>
<div
role="tablist"
aria-label="Navigation tabs"
aria-orientation="horizontal"
className={cn(
"absolute bottom-0 left-0 z-20 flex w-full items-center justify-between gap-1 p-2",
classNames?.bar,
)}
style={{ height: BAR_H }}
>
{items.map((item) => {
const isActive = item.id === visualActiveId;
const activeTabWidth = getActiveTabWidth(item);
const labelWidth = labelWidths[item.id] ?? 0;
return (
<motion.button
key={item.id}
type="button"
role="tab"
aria-selected={isActive}
aria-label={item.label}
onClick={() => setActive(isActive ? null : item.id)}
layout={reduce ? false : "position"}
animate={{
width: active && isActive ? activeTabWidth : TAB_W,
}}
transition={reduce ? { duration: 0 } : TAB_CHANGE_SPRING}
className={cn(
"relative isolate flex h-9 min-w-8 shrink-0 items-center justify-center overflow-hidden rounded-[18px] px-2 text-sm font-medium outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
active && isActive && "min-w-0 justify-start pl-2.5 pr-4",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
classNames?.tab,
isActive && classNames?.activeTab,
)}
>
{isActive ? (
<span
className={cn(
"absolute inset-0 -z-10 rounded-[18px] bg-foreground/10",
classNames?.pill,
)}
/>
) : null}
<span
className={cn(
"grid shrink-0 place-items-center",
classNames?.icon,
)}
>
{item.icon}
</span>
<motion.span
aria-hidden
initial={false}
animate={
reduce
? {
width: isActive ? labelWidth : 0,
opacity: isActive ? 1 : 0,
marginLeft: isActive ? LABEL_GAP : 0,
filter: "blur(0px)",
}
: {
width: isActive ? labelWidth : 0,
opacity: isActive ? 1 : 0,
marginLeft: isActive ? LABEL_GAP : 0,
filter: isActive ? "blur(0px)" : "blur(3px)",
}
}
transition={
reduce
? { duration: 0 }
: isActive
? LABEL_OPEN
: LABEL_CLOSE
}
className={cn(
"inline-block overflow-hidden whitespace-nowrap",
classNames?.label,
)}
>
{item.label}
</motion.span>
</motion.button>
);
})}
</div>
</motion.div>
<div
aria-hidden="true"
className="pointer-events-none fixed left-0 top-0 -z-10 flex opacity-0"
>
{items.map((item) => (
<span
className={cn(
"whitespace-nowrap text-sm font-medium leading-none",
classNames?.label,
)}
key={item.id}
ref={setLabelMeasureRef(item.id)}
>
{item.label}
</span>
))}
</div>
</>
);
}
API Reference
items{}—value?string | nullActive tab id, or null/undefined for the closed (bar-only) state.
—defaultValue?string | nullnullonValueChange?((id: string | null) => void)—className?string—classNames?ExpandableTabsClassNames—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated