Expandable Tabs
Icon tabs that expand to reveal their label when selected.
- tabs
- navigation
- icons
- animation
Preview
Installation
The CLI command below is illustrative until the registry endpoint ships. Until then, copy the source from the Code tab into components/fmui/. Every component expects Tailwind CSS v4, the cn helper (clsx + tailwind-merge), and Hugeicons.
$ pnpm dlx shadcn@latest add https://fmui.dev/r/expandable-tabs.jsonexpandable-tabs/expandable-tabs.tsx
Usage
example.tsx
import { ExpandableTabs } from "@/components/fmui/expandable-tabs";
import { Home01Icon, Mail01Icon } from "@hugeicons/core-free-icons";
<ExpandableTabs
tabs={[
{ id: "home", label: "Home", icon: Home01Icon },
{ id: "inbox", label: "Inbox", icon: Mail01Icon },
]}
defaultValue="home"
onValueChange={(id) => console.log(id)}
/>Variants
Uncontrolled
Pass defaultValue and let the component own its selection.
Controlled
Pass value and onValueChange to sync with router state or a store.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | ExpandableTab[] | - | Tabs with an id, label, and Hugeicons icon. |
| value | string | - | Selected tab id (controlled). |
| defaultValue | string | tabs[0].id | Initial selection (uncontrolled). |
| onValueChange | (id: string) => void | - | Called when the selection changes. |
| className | string | - | Extra classes for the tab list. |
Accessibility
- Rendered as role=tablist with role=tab buttons and aria-selected.
- Roving tabindex: Left/Right arrows, Home and End move between tabs.
- Collapsed labels stay in the accessible name via a visually hidden span.
Source
"use client";
import { useId, useRef, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { cn } from "@/lib/utils";
export interface ExpandableTab {
id: string;
label: string;
icon: IconSvgElement;
}
export interface ExpandableTabsProps {
tabs: ExpandableTab[];
value?: string;
defaultValue?: string;
onValueChange?: (id: string) => void;
className?: string;
}
/**
* Icon-only tabs that expand to show their label when selected.
* The label animates with a grid-template-columns transition so the
* expansion runs on the compositor-friendly path without measuring.
*/
export function ExpandableTabs({ tabs, value, defaultValue, onValueChange, className }: ExpandableTabsProps) {
const [internal, setInternal] = useState(defaultValue ?? tabs[0]?.id);
const selected = value ?? internal;
const baseId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
function select(id: string) {
setInternal(id);
onValueChange?.(id);
}
function onKeyDown(e: React.KeyboardEvent, index: number) {
const last = tabs.length - 1;
let next: number | null = null;
if (e.key === "ArrowRight") next = index === last ? 0 : index + 1;
if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1;
if (e.key === "Home") next = 0;
if (e.key === "End") next = last;
if (next === null) return;
e.preventDefault();
select(tabs[next].id);
refs.current[next]?.focus();
}
return (
<div
role="tablist"
aria-orientation="horizontal"
className={cn(
"inline-flex items-center gap-1 rounded-[12px] border border-border bg-background p-1 shadow-[0_1px_2px_rgb(0_0_0/0.04)]",
className,
)}
>
{tabs.map((tab, i) => {
const active = tab.id === selected;
return (
<button
key={tab.id}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="tab"
id={`${baseId}-${tab.id}`}
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => select(tab.id)}
onKeyDown={(e) => onKeyDown(e, i)}
className={cn(
"flex h-9 items-center rounded-[9px] px-2.5 text-sm font-medium transition-[background-color,color,transform] duration-200 ease-out-strong active:scale-[0.97]",
active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<HugeiconsIcon icon={tab.icon} size={18} strokeWidth={2} className="shrink-0" />
<span
className="grid transition-[grid-template-columns] duration-250 ease-out-strong"
style={{ gridTemplateColumns: active ? "1fr" : "0fr" }}
aria-hidden={!active}
>
<span className="overflow-hidden whitespace-nowrap">
<span className="block pl-2 pr-0.5">{tab.label}</span>
</span>
</span>
{!active && <span className="sr-only">{tab.label}</span>}
</button>
);
})}
</div>
);
}
Added Oct 6, 2026. Last updated Oct 23, 2026.