Sidebar

Collapsible sidebar navigation layout.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/sidebar.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { ChevronDown } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useMounted } from "../../lib/use-mounted"; import { cn } from "../../lib/utils"; import { useSidebar } from "../sidebar-provider/sidebar-provider"; export type SidebarItem = { href: string; title: string; }; export type SidebarSection = { collapsible?: boolean; defaultOpen?: boolean; items: SidebarItem[]; title?: string; }; type SidebarProps = { sections: SidebarSection[]; }; const getMobileSnapshot = () => typeof window === "undefined" ? false : window.innerWidth < 1024; const getServerMobileSnapshot = () => false; const subscribeToViewportResize = (onStoreChange: () => void) => { window.addEventListener("resize", onStoreChange); return () => { window.removeEventListener("resize", onStoreChange); }; }; function useMobile(setOpen: (open: boolean) => void) { const isMobile = useSyncExternalStore( subscribeToViewportResize, getMobileSnapshot, getServerMobileSnapshot, ); useEffect(() => { setOpen(!isMobile); }, [isMobile, setOpen]); return isMobile; } function useScrollFade( containerReference: React.RefObject<HTMLDivElement | null>, ) { const [showTopFade, setShowTopFade] = useState(false); const [showBottomFade, setShowBottomFade] = useState(false); useEffect(() => { const container = containerReference.current; if (!container) return; const checkScroll = () => { const { clientHeight, scrollHeight, scrollTop } = container; setShowTopFade(scrollTop > 0); setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); }; checkScroll(); container.addEventListener("scroll", checkScroll, { passive: true }); return () => { container.removeEventListener("scroll", checkScroll); }; }, [containerReference]); return { showBottomFade, showTopFade }; } type CollapsibleSectionProps = { children: React.ReactNode; collapsible?: boolean; defaultOpen?: boolean; title: string; }; function CollapsibleSection({ children, collapsible = false, defaultOpen = true, title, }: CollapsibleSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); if (!collapsible) { return ( <> <div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider"> {title} </div> {children} </> ); } return ( <> <button className="flex w-full items-center justify-between px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors" onClick={() => { setIsOpen(!isOpen); }} type="button" > <span>{title}</span> <ChevronDown className={cn( "size-3 transition-transform duration-200", isOpen && "rotate-180", )} /> </button> <div className={cn( "grid transition-all duration-200 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", )} hidden={!isOpen} > <div className="overflow-hidden">{children}</div> </div> </> ); } // eslint-disable-next-line max-lines-per-function export function Sidebar({ sections }: SidebarProps) { const pathname = usePathname(); const { open, setOpen } = useSidebar(); const isMobile = useMobile(setOpen); const mounted = useMounted(); const scrollContainerReference = useRef<HTMLDivElement>(null); const { showBottomFade, showTopFade } = useScrollFade( scrollContainerReference, ); const collapsed = mounted && !isMobile && !open; return ( <> {/* Mobile overlay */} {isMobile && open ? ( <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" data-testid="sidebar-overlay" onClick={() => { setOpen(false); }} onKeyDown={(event) => { if (event.key === "Escape") { setOpen(false); } }} role="button" tabIndex={0} /> ) : null} {/* Sidebar */} <aside className={cn( "fixed lg:relative top-16 lg:top-0 bottom-0 lg:bottom-auto left-0 z-40 lg:h-full bg-background transition-[transform,width] duration-300 ease-in-out", "flex flex-col", "overflow-hidden", "shrink-0", collapsed ? "border-r-0" : "border-r", collapsed ? "w-0" : isMobile ? "w-full" : "w-64", isMobile && open && "translate-x-0", isMobile && !open && "-translate-x-full", !isMobile && !collapsed && "-translate-x-full lg:translate-x-0", !isMobile && collapsed && "-translate-x-full", )} > <div className="relative flex-1 overflow-hidden"> {/* Top fade */} {showTopFade ? ( <div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none z-20" /> ) : null} {/* Bottom fade */} {showBottomFade ? ( <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none z-20" /> ) : null} <nav className="flex-1 p-4 overflow-y-auto overscroll-contain h-full [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref={scrollContainerReference} > <div className="space-y-4"> {sections.map((section, sectionIndex) => { const sectionItems = ( <div className={section.title ? "space-y-0.5" : "space-y-1"}> {section.items.map((item) => ( <Link className={cn( section.title ? "block px-3 py-1.5 rounded-md text-sm transition-colors" : "flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors", pathname === item.href || (item.href === "/" && pathname === "/") ? "bg-accent text-accent-foreground" : section.title ? "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground", section.title && pathname === item.href && "font-medium", )} href={item.href} key={item.href} onClick={() => { if (isMobile) { setOpen(false); } }} > {item.title} </Link> ))} </div> ); return ( <div className="space-y-1" key={section.title || sectionIndex} > {section.title ? ( <CollapsibleSection collapsible={section.collapsible} defaultOpen={section.defaultOpen ?? true} title={section.title} > {sectionItems} </CollapsibleSection> ) : ( sectionItems )} </div> ); })} </div> </nav> </div> </aside> </> ); }

Dependencies

  • @vllnt/ui@^0.2.1