Navigationv0.1.0
Collapsible Sidebar
Collapses to icons while labels fade out.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/collapsible-sidebar.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/collapsible-sidebar/collapsible-sidebar.tsx"use client"; import { useId, useState, type ComponentProps, type ElementType, type FocusEvent, type MouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; export interface SidebarItem { /** Unique id. */ id: string; /** Label; hidden when collapsed but always the accessible name. */ label: string; /** Icon shown in both states. */ icon: ReactNode; /** Destination. Without it the item renders as a button. */ href?: string; /** Small count or tag after the label, hidden when collapsed. */ badge?: ReactNode; } export interface SidebarSection { /** Heading above the items; a divider stands in for it when collapsed. */ title?: string; /** Items in the section. */ items: SidebarItem[]; } export interface CollapsibleSidebarProps extends Omit<ComponentProps<"aside">, "children" | "onSelect" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Navigation sections. */ sections: SidebarSection[]; /** Controlled collapsed state. */ collapsed?: boolean; /** Initial collapsed state when uncontrolled. @default false */ defaultCollapsed?: boolean; /** Called when the sidebar collapses or expands. */ onCollapsedChange?: (collapsed: boolean) => void; /** Id of the current item. */ activeId?: string; /** Called when an item is chosen. */ onSelect?: (id: string, event: MouseEvent<HTMLElement>) => void; /** Brand shown at the top: an icon that stays, and a name that fades. */ brand?: { icon: ReactNode; name: string }; /** Link component for items with href, e.g. Next.js Link. @default "a" */ linkComponent?: ElementType; } const LAYOUT = { type: "spring", stiffness: 380, damping: 38 } as const; export function CollapsibleSidebar({ sections, collapsed, defaultCollapsed = false, onCollapsedChange, activeId, onSelect, brand, linkComponent: Link = "a", className, "aria-label": ariaLabel = "Sidebar", ...props }: CollapsibleSidebarProps) { const [internal, setInternal] = useState(defaultCollapsed); const isCollapsed = collapsed ?? internal; const [tip, setTip] = useState<{ label: string; x: number; y: number } | null>(null); const reduceMotion = useReducedMotion(); const navId = useId(); const groupId = useId(); const layout = reduceMotion ? { duration: 0 } : LAYOUT; // The nav scrolls, so it can't let a tooltip overflow; collapsed labels render in a portal instead. const showTip = (labelText: string, element: HTMLElement) => { const rect = element.getBoundingClientRect(); setTip({ label: labelText, x: rect.right + 12, y: rect.top + rect.height / 2 }); }; const toggle = () => { setTip(null); if (collapsed === undefined) setInternal(!isCollapsed); onCollapsedChange?.(!isCollapsed); }; // Labels vanish quickly before the sidebar narrows, and fade back in once it has widened. const label = { initial: { opacity: 0, x: reduceMotion ? 0 : -4 }, animate: { opacity: 1, x: 0, transition: { duration: duration.quick, ease: ease.out, delay: reduceMotion ? 0 : 0.08 } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; return ( <motion.aside layout transition={layout} data-collapsed={isCollapsed || undefined} className={cn( "group/sidebar flex h-full shrink-0 flex-col border-r border-border bg-background", isCollapsed ? "w-[3.75rem]" : "w-60", className, )} {...props} > <div className="flex h-14 shrink-0 items-center gap-2.5 px-3.5"> {brand ? ( <> <motion.span layout="position" transition={layout} className="flex size-8 shrink-0 items-center justify-center" aria-hidden="true"> {brand.icon} </motion.span> <AnimatePresence initial={false}> {!isCollapsed ? ( <motion.span key="brand" {...label} className="truncate text-sm font-semibold text-foreground"> {brand.name} </motion.span> ) : null} </AnimatePresence> </> ) : null} </div> <nav id={navId} aria-label={ariaLabel} className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-2.5 pb-2"> <LayoutGroup id={groupId}> {sections.map((section, sectionIndex) => ( <div key={section.title ?? sectionIndex} className="mt-3 first:mt-1"> {section.title ? ( <div className="relative mb-1 flex h-6 items-center px-2"> <AnimatePresence initial={false} mode="popLayout"> {isCollapsed ? ( <motion.span key="divider" {...label} aria-hidden="true" className="block h-px w-full bg-border" /> ) : ( <motion.p key="title" {...label} className="text-xs font-medium whitespace-nowrap text-muted-foreground"> {section.title} </motion.p> )} </AnimatePresence> </div> ) : null} <ul className="space-y-0.5"> {section.items.map((item) => { const active = item.id === activeId; const Tag = item.href ? Link : "button"; return ( <li key={item.id} className="group/item relative"> <Tag href={item.href} type={item.href ? undefined : "button"} aria-current={active ? "page" : undefined} aria-label={isCollapsed ? item.label : undefined} onClick={(event: MouseEvent<HTMLElement>) => onSelect?.(item.id, event)} onPointerEnter={(event: MouseEvent<HTMLElement>) => isCollapsed && showTip(item.label, event.currentTarget)} onPointerLeave={() => setTip(null)} onFocus={(event: FocusEvent<HTMLElement>) => isCollapsed && event.currentTarget.matches(":focus-visible") && showTip(item.label, event.currentTarget) } onBlur={() => setTip(null)} className={cn( "relative flex h-9 w-full cursor-pointer items-center gap-3 rounded-md px-2.5 text-sm whitespace-nowrap outline-none", "transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[18px] [&_svg]:shrink-0", active ? "font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > {active ? ( <motion.span layoutId="active-item" aria-hidden="true" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-md bg-accent" /> ) : null} <motion.span layout="position" transition={layout} className="relative flex" aria-hidden="true"> {item.icon} </motion.span> <AnimatePresence initial={false}> {!isCollapsed ? ( <motion.span key="label" {...label} className="relative flex min-w-0 flex-1 items-center gap-2"> <span className="truncate">{item.label}</span> {item.badge !== undefined ? ( <span className="ml-auto rounded-full bg-muted px-1.5 text-[11px] font-medium text-foreground/65 tabular-nums"> {item.badge} </span> ) : null} </motion.span> ) : null} </AnimatePresence> </Tag> </li> ); })} </ul> </div> ))} </LayoutGroup> </nav> {tip && isCollapsed ? createPortal( <span aria-hidden="true" className="pointer-events-none fixed z-[60] -translate-y-1/2 rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background shadow-md" style={{ left: tip.x, top: tip.y }} > {tip.label} </span>, document.body, ) : null} <div className="shrink-0 border-t border-border p-2.5"> <button type="button" onClick={toggle} aria-expanded={!isCollapsed} aria-controls={navId} aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 flex h-9 w-full cursor-pointer items-center gap-3 rounded-md px-2.5 text-sm text-muted-foreground outline-none transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[18px] [&_svg]:shrink-0" > <motion.span layout="position" transition={layout} className="flex" aria-hidden="true"> {isCollapsed ? <PanelLeftOpen /> : <PanelLeftClose />} </motion.span> <AnimatePresence initial={false}> {!isCollapsed ? ( <motion.span key="toggle" {...label} aria-hidden="true"> Collapse </motion.span> ) : null} </AnimatePresence> </button> </div> </motion.aside> ); }
Usage
import Link from "next/link";
import { CollapsibleSidebar } from "@/components/ui/collapsible-sidebar/collapsible-sidebar";
export function AppSidebar({ active }: { active: string }) {
return (
<CollapsibleSidebar
linkComponent={Link}
activeId={active}
sections={[
{ items: [{ id: "home", label: "Home", icon: <HomeIcon />, href: "/" }] },
{ title: "Workspace", items: [{ id: "settings", label: "Settings", icon: <GearIcon />, href: "/settings" }] },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
sections* | SidebarSection[] | – | Navigation sections. |
collapsed | boolean | – | Controlled collapsed state. |
defaultCollapsed | boolean | false | Initial collapsed state when uncontrolled. |
onCollapsedChange | (collapsed: boolean) => void | – | Called when the sidebar collapses or expands. |
activeId | string | – | Id of the current item. |
onSelect | (id: string, event: MouseEvent<HTMLElement>) => void | – | Called when an item is chosen. |
brand | { icon: ReactNode; name: string } | – | Brand shown at the top: an icon that stays, and a name that fades. |
linkComponent | ElementType | "a" | Link component for items with href, e.g. Next.js Link. |
Also accepts every native <aside> attribute, including ref and className.
- The width change is a Motion layout animation: icons hold position, and labels leave before the sidebar narrows and fade back after it widens.
- Give neighbouring content layout too (as in the demo) so it glides along instead of jumping.
- Collapsed tooltips render in a portal, because the scrolling nav can't let them overflow.
Accessibility
- Items live in a labelled <nav>; the current one has aria-current="page".
- The toggle is a button with aria-expanded and aria-controls, labelled "Collapse sidebar" or "Expand sidebar".
- When collapsed, items keep their label as aria-label, and a tooltip shows it on hover and keyboard focus.
- With reduced motion enabled, the width changes instantly and labels fade in place.