Navigation · Free prompt
Expandable Tabs
Expandable Tabs is a complete, paste-ready navigation build-prompt — a full art-direction spec for Lovable, Bolt, v0, Cursor and Claude. It's free: copy it below and paste it into your builder.

Expandable TabsNavigation · free prompt · copy-paste
A set of expandable tabs that show both icon and text when selected. Tabs animate smoothly to expand or collapse on click, and clicking outside collapses any open tab.
Features
- Animated Expansion: Smooth animations when revealing tab labels
- Space Efficient: Collapses to icons-only when not in use
- Click Outside: Automatically collapses when clicking outside
- Separator Support: Optional separators between tab groups
- Customizable: Supports custom colors and styling
- Shadcn/UI Theme: Fully integrated with shadcn/ui theming system
- Dark Mode: Built-in dark mode support
- Accessible: Keyboard navigation and screen reader friendly
Example Use Cases
- Navigation Menus: Compact sidebar navigation
- Toolbars: Feature-rich toolbars that save space
- Mobile Interfaces: Touch-friendly navigation
- Settings Panels: Grouped settings controls
- Dashboard Controls: Quick access to main features
— Install —
npx shadcn@latest add "https://21st.dev/r/expandable-tabs"
— Component source (Expandable Tabs.tsx) —
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useOnClickOutside } from "usehooks-ts";
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
interface Tab {
title: string;
icon: LucideIcon;
type?: never;
}
interface Separator {
type: "separator";
title?: never;
icon?: never;
}
type TabItem = Tab | Separator;
interface ExpandableTabsProps {
tabs: TabItem[];
className?: string;
activeColor?: string;
onChange?: (index: number | null) => void;
}
const buttonVariants = {
initial: {
gap: 0,
paddingLeft: ".5rem",
paddingRight: ".5rem",
},
animate: (isSelected: boolean) => ({
gap: isSelected ? ".5rem" : 0,
paddingLeft: isSelected ? "1rem" : ".5rem",
paddingRight: isSelected ? "1rem" : ".5rem",
}),
};
const spanVariants = {
initial: { width: 0, opacity: 0 },
animate: { width: "auto", opacity: 1 },
exit: { width: 0, opacity: 0 },
};
const transition = { delay: 0.1, type: "spring", bounce: 0, duration: 0.6 };
export function ExpandableTabs({
tabs,
className,
activeColor = "text-primary",
onChange,
}: ExpandableTabsProps) {
const [selected, setSelected] = React.useState<number | null>(null);
const outsideClickRef = React.useRef(null);
useOnClickOutside(outsideClickRef, () => {
setSelected(null);
onChange?.(null);
});
const handleSelect = (index: number) => {
setSelected(index);
onChange?.(index);
};
const Separator = () => (
<div className="mx-1 h-[24px] w-[1.2px] bg-border" aria-hidden="true" />
);
return (
<div
ref={outsideClickRef}
className={cn(
"flex flex-wrap items-center gap-2 rounded-2xl border bg-background p-1 shadow-sm",
className
)}
>
{tabs.map((tab, index) => {
if (tab.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
const Icon = tab.icon;
return (
<motion.button
key={tab.title}
variants={buttonVariants}
initial={false}
animate="animate"
custom={selected === index}
onClick={() => handleSelect(index)}
transition={transition}
className={cn(
"relative flex items-center rounded-xl px-4 py-2 text-sm font-medium transition-colors duration-300",
selected === index
? cn("bg-muted", activeColor)
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon size={20} />
<AnimatePresence initial={false}>
{selected === index && (
<motion.span
variants={spanVariants}
initial="initial"
animate="animate"
exit="exit"
transition={transition}
className="overflow-hidden"
>
{tab.title}
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})}
</div>
);
}
Browse & preview free Unlock everything — from $19/mo





