Sidebar.
A composable app sidebar that collapses off canvas or down to icons, and turns into a sheet on phones.
"use client";
import * as React from "react";
import {
BookOpenIcon,
ChevronRightIcon,
ChevronsUpDownIcon,
CpuIcon,
LayoutDashboardIcon,
MoreHorizontalIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
TerminalSquareIcon,
} from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
} from "@/components/ui/sidebar";
const hub = [
{ title: "Overview", icon: LayoutDashboardIcon },
{ title: "Servers", icon: ServerIcon, badge: "3" },
{ title: "Guestbook", icon: BookOpenIcon, badge: "12" },
{ title: "Terminal", icon: TerminalSquareIcon },
];
const turtles = ["miner-01", "miner-02", "farmer-07"];
export default function SidebarDemo() {
const [active, setActive] = React.useState("Overview");
return (
<div className="h-[560px] w-full overflow-hidden rounded-lg border">
{/* Inside a docs preview: fill the frame instead of the viewport. */}
<SidebarProvider className="relative h-full min-h-0">
<Sidebar className="absolute h-full">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="rounded-md border-transparent">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<CpuIcon className="size-4" />
</div>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-display font-medium text-sidebar-foreground">North hub</span>
<span className="truncate font-mono text-[10px] tracking-[0.12em] uppercase">Computer 42</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarInput placeholder="Search the hub" aria-label="Search the hub" />
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Hub</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{hub.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={active === item.title}
onClick={() => setActive(item.title)}
tooltip={item.title}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Fleet</SidebarGroupLabel>
<SidebarGroupAction aria-label="Add turtle">
<PlusIcon />
</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
<Collapsible defaultOpen asChild className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip="Turtles">
<CpuIcon />
<span>Turtles</span>
<ChevronRightIcon className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{turtles.map((t) => (
<SidebarMenuSubItem key={t}>
<SidebarMenuSubButton
href="#"
isActive={active === t}
onClick={(e) => {
e.preventDefault();
setActive(t);
}}
>
<span className="font-mono text-xs">{t}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton
isActive={active === "Settings"}
onClick={() => setActive("Settings")}
tooltip="Settings"
>
<SettingsIcon />
<span>Settings</span>
</SidebarMenuButton>
<SidebarMenuAction showOnHover aria-label="More settings">
<MoreHorizontalIcon />
</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 rounded-md border border-sidebar-border bg-background/40 px-2.5 py-2 group-data-[collapsible=icon]:hidden">
<span className="size-1.5 shrink-0 animate-lantern-pulse rounded-full bg-success" aria-hidden="true" />
<span className="font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase">Relay online</span>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1 data-[orientation=vertical]:h-4" />
<span className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">North hub</span>
<span className="font-mono text-[10px] text-muted-foreground">/</span>
<span className="truncate font-mono text-[10px] tracking-[0.2em] text-foreground uppercase">{active}</span>
</header>
<div className="@container flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
<div className="grid gap-3 @sm:grid-cols-3">
{[
["Turtles online", "3 / 4"],
["Fuel stored", "12,480"],
["Guestbook", "12 new"],
].map(([label, value]) => (
<div key={label} className="rounded-lg border bg-card p-4">
<div className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">{label}</div>
<div className="mt-2 font-display text-xl font-medium tracking-tight whitespace-nowrap">{value}</div>
</div>
))}
</div>
<div className="min-h-40 flex-1 rounded-lg border bg-card bg-grid p-4">
<div className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">Recent activity</div>
<ul className="mt-3 grid gap-2 font-mono text-xs text-muted-foreground">
<li>
<span className="text-primary">></span> miner-02 returned to base with 64 coal
</li>
<li>
<span className="text-primary">></span> new guestbook entry from computer 17
</li>
<li>
<span className="text-primary">></span> relay handshake ok on lantern-main
</li>
</ul>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</div>
);
}Installation#
With the CLI
$npx lanterncn add sidebarThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/sidebar. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install radix-ui lucide-react class-variance-authorityCopy the source into your project
components/ui/sidebar.tsx"use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import { PanelLeftIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContextProps = { state: "expanded" | "collapsed"; open: boolean; setOpen: (open: boolean | ((open: boolean) => boolean)) => void; openMobile: boolean; setOpenMobile: (open: boolean | ((open: boolean) => boolean)) => void; isMobile: boolean; toggleSidebar: () => void; }; const SidebarContext = React.createContext<SidebarContextProps | null>(null); function useSidebar() { const context = React.useContext(SidebarContext); if (!context) { throw new Error("useSidebar must be used within a SidebarProvider."); } return context; } function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, keyboardShortcut = true, className, style, children, ...props }: React.ComponentProps<"div"> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; /** Toggle with Cmd/Ctrl+B. Turn off when several sidebars share a page. */ keyboardShortcut?: boolean; }) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); // Internal state unless the caller controls `open`. Nothing is written to cookies or storage. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value; if (setOpenProp) setOpenProp(openState); else _setOpen(openState); }, [setOpenProp, open], ); const toggleSidebar = React.useCallback(() => { if (isMobile) setOpenMobile((o) => !o); else setOpen((o) => !o); }, [isMobile, setOpen]); React.useEffect(() => { if (!keyboardShortcut) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) && !event.altKey) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [keyboardShortcut, toggleSidebar]); const state = open ? "expanded" : "collapsed"; const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }), [state, open, setOpen, isMobile, openMobile, toggleSidebar], ); return ( <SidebarContext.Provider value={contextValue}> <TooltipProvider delayDuration={0}> <div data-slot="sidebar-wrapper" style={ { "--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties } className={cn("group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className)} {...props} > {children} </div> </TooltipProvider> </SidebarContext.Provider> ); } function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }: React.ComponentProps<"div"> & { side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; }) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === "none") { return ( <div data-slot="sidebar" className={cn("flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", className)} {...props} > {children} </div> ); } if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" side={side} showCloseButton={false} className="w-(--sidebar-width) max-w-[85vw] gap-0 overflow-hidden bg-sidebar p-0 text-sidebar-foreground sm:max-w-[85vw]" style={{ "--sidebar-width": SIDEBAR_WIDTH_MOBILE } as React.CSSProperties} > <SheetHeader className="sr-only"> <SheetTitle>Sidebar</SheetTitle> <SheetDescription>Displays the mobile sidebar.</SheetDescription> </SheetHeader> <div className="flex h-full w-full flex-col">{children}</div> </SheetContent> </Sheet> ); } return ( <div className="group peer hidden text-sidebar-foreground md:block" data-state={state} data-collapsible={state === "collapsed" ? collapsible : ""} data-variant={variant} data-side={side} data-slot="sidebar" > {/* Takes up the sidebar's width in the layout so the inset sits beside it. */} <div data-slot="sidebar-gap" className={cn( "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", "group-data-[collapsible=offcanvas]:w-0", "group-data-[side=right]:rotate-180", variant === "floating" || variant === "inset" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", )} /> <div data-slot="sidebar-container" className={cn( "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", side === "left" ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", variant === "floating" || variant === "inset" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", "border-sidebar-border", className, )} {...props} > <div data-sidebar="sidebar" data-slot="sidebar-inner" className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-block-sm" > {children} </div> </div> </div> ); } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { const { toggleSidebar, open, openMobile, isMobile } = useSidebar(); return ( <Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon-sm" aria-expanded={isMobile ? openMobile : open} className={cn("size-8", className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > <PanelLeftIcon /> <span className="sr-only">Toggle sidebar</span> </Button> ); } function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { const { toggleSidebar } = useSidebar(); return ( <button data-sidebar="rail" data-slot="sidebar-rail" aria-label="Toggle sidebar" tabIndex={-1} onClick={toggleSidebar} title="Toggle sidebar" className={cn( "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-px after:transition-colors hover:after:bg-sidebar-primary sm:flex", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", className, )} {...props} /> ); } function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return ( <main data-slot="sidebar-inset" className={cn( "relative flex w-full min-w-0 flex-1 flex-col bg-background", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", className, )} {...props} /> ); } function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { return ( <Input data-slot="sidebar-input" data-sidebar="input" className={cn("h-8 w-full bg-background text-[13px]", className)} {...props} /> ); } function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="sidebar-header" data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />; } function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="sidebar-footer" data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />; } function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { return ( <Separator data-slot="sidebar-separator" data-sidebar="separator" className={cn("mx-2 w-auto bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} /> ); } function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-content" data-sidebar="content" className={cn( "flex min-h-0 flex-1 flex-col gap-2 overflow-auto [scrollbar-color:var(--color-sidebar-border)_transparent] [scrollbar-width:thin] group-data-[collapsible=icon]:overflow-hidden", className, )} {...props} /> ); } function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-group" data-sidebar="group" className={cn("relative flex w-full min-w-0 flex-col p-2", className)} {...props} /> ); } function SidebarGroupLabel({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "div"; return ( <Comp data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( "flex h-8 shrink-0 items-center rounded-md px-2 font-mono text-[10px] font-semibold tracking-[0.2em] text-success uppercase outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 focus-visible:ring-sidebar-ring/40 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )} {...props} /> ); } function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "button"; return ( <Comp data-slot="sidebar-group-action" data-sidebar="group-action" className={cn( "absolute top-3.5 right-3 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-sm p-0 text-muted-foreground outline-hidden transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring/40 [&>svg]:size-4 [&>svg]:shrink-0", // Bigger hit area on mobile. "after:absolute after:-inset-2 md:after:hidden", "group-data-[collapsible=icon]:hidden", className, )} {...props} /> ); } function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-group-content" data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} /> ); } function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="sidebar-menu" data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-px", className)} {...props} /> ); } function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { return ( <li data-slot="sidebar-menu-item" data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} /> ); } const sidebarMenuButtonVariants = cva( [ "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-r-md border-l border-transparent p-2 text-left text-[13.5px] text-muted-foreground outline-hidden transition-[width,height,padding,color,background-color,border-color]", "hover:border-input hover:bg-sidebar-accent/60 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring/40 active:bg-sidebar-accent", "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", "group-has-data-[sidebar=menu-action]/menu-item:pr-8", "data-[active=true]:border-sidebar-primary data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary", "data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground", "group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2!", "[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", ].join(" "), { variants: { variant: { default: "", outline: "rounded-md border border-sidebar-border bg-background hover:border-input data-[active=true]:border-sidebar-primary", }, size: { default: "h-8", sm: "h-7 text-xs", lg: "h-12 group-data-[collapsible=icon]:p-0!", }, }, defaultVariants: { variant: "default", size: "default" }, }, ); function SidebarMenuButton({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }: React.ComponentProps<"button"> & { asChild?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps<typeof TooltipContent>; } & VariantProps<typeof sidebarMenuButtonVariants>) { const Comp = asChild ? Slot.Root : "button"; const { isMobile, state } = useSidebar(); const button = ( <Comp data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size={size} data-active={isActive} aria-current={isActive ? "page" : undefined} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} {...props} /> ); if (!tooltip) return button; const tooltipProps = typeof tooltip === "string" ? { children: tooltip } : tooltip; return ( <Tooltip> <TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltipProps} /> </Tooltip> ); } function SidebarMenuAction({ className, asChild = false, showOnHover = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) { const Comp = asChild ? Slot.Root : "button"; return ( <Comp data-slot="sidebar-menu-action" data-sidebar="menu-action" className={cn( "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-sm p-0 text-muted-foreground outline-hidden transition-colors hover:bg-sidebar-border hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring/40 peer-hover/menu-button:text-sidebar-foreground [&>svg]:size-4 [&>svg]:shrink-0", "after:absolute after:-inset-2 md:after:hidden", "peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-3.5", "group-data-[collapsible=icon]:hidden", showOnHover && "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground data-[state=open]:opacity-100 md:opacity-0", className, )} {...props} /> ); } function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-menu-badge" data-sidebar="menu-badge" className={cn( "pointer-events-none absolute right-1.5 flex h-5 min-w-5 items-center justify-center rounded-sm px-1 font-mono text-[10px] tabular-nums tracking-wider text-muted-foreground select-none", "peer-hover/menu-button:text-sidebar-foreground peer-data-[active=true]/menu-button:text-sidebar-primary", "peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-3.5", "group-data-[collapsible=icon]:hidden", className, )} {...props} /> ); } function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<"div"> & { showIcon?: boolean }) { // A stable width between 50% and 90%, derived from the id so server and client agree. const id = React.useId(); const width = React.useMemo(() => { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0; return `${50 + (hash % 41)}%`; }, [id]); return ( <div data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} {...props} > {showIcon && <Skeleton className="size-4 rounded-sm" data-sidebar="menu-skeleton-icon" />} <Skeleton className="h-3.5 max-w-(--skeleton-width) flex-1" data-sidebar="menu-skeleton-text" style={{ "--skeleton-width": width } as React.CSSProperties} /> </div> ); } function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" className={cn( "mx-3.5 flex min-w-0 translate-x-px flex-col gap-px border-l border-sidebar-border px-2.5 py-0.5", "group-data-[collapsible=icon]:hidden", className, )} {...props} /> ); } function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { return ( <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" className={cn("group/menu-sub-item relative", className)} {...props} /> ); } function SidebarMenuSubButton({ asChild = false, size = "md", isActive = false, className, ...props }: React.ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) { const Comp = asChild ? Slot.Root : "a"; return ( <Comp data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size={size} data-active={isActive} aria-current={isActive ? "page" : undefined} className={cn( "flex h-7 min-w-0 -translate-x-px cursor-pointer items-center gap-2 overflow-hidden rounded-md px-2 text-muted-foreground outline-hidden transition-colors hover:bg-sidebar-accent/60 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring/40 active:bg-sidebar-accent", "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary", "[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", size === "sm" && "text-xs", size === "md" && "text-[13px]", "group-data-[collapsible=icon]:hidden", className, )} {...props} /> ); } export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, useSidebar, };hooks/use-mobile.tsimport * as React from "react"; const MOBILE_BREAKPOINT = 768; const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`; function subscribe(onChange: () => void) { const mql = window.matchMedia(QUERY); mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); } /** True below the md breakpoint (768px). Always false during server rendering. */ export function useIsMobile() { return React.useSyncExternalStore( subscribe, () => window.matchMedia(QUERY).matches, () => false, ); }Update the import paths to match your project
Usage#
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Hub</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive tooltip="Servers">
<ServerIcon />
<span>Servers</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<SidebarTrigger />
</SidebarInset>
</SidebarProvider>SidebarProvider holds the open state in React state only; nothing is written to cookies. Pass open and onOpenChange to control it yourself. Cmd+B or Ctrl+B toggles it; set keyboardShortcut={false} when a page has more than one sidebar.
Set variant to sidebar, floating or inset, and collapsible to offcanvas, icon or none. Below 768px the sidebar opens as a sheet from SidebarTrigger.
The sidebar is fixed to the viewport by default. To embed it in a box, give SidebarProvider className="relative h-full min-h-0" and Sidebar className="absolute h-full", as the examples here do.
The use-mobile hook is installed alongside the component at hooks/use-mobile.ts.
Examples#
Collapse to icons
"use client";
import * as React from "react";
import {
BookOpenIcon,
CpuIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
TerminalSquareIcon,
} from "lucide-react";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
const items = [
{ title: "Overview", icon: LayoutDashboardIcon },
{ title: "Servers", icon: ServerIcon, badge: "3" },
{ title: "Turtles", icon: CpuIcon, badge: "4" },
{ title: "Guestbook", icon: BookOpenIcon },
{ title: "Terminal", icon: TerminalSquareIcon },
];
export default function SidebarIcon() {
const [active, setActive] = React.useState("Servers");
return (
<div className="h-[460px] w-full overflow-hidden rounded-lg border">
<SidebarProvider defaultOpen={false} keyboardShortcut={false} className="relative h-full min-h-0">
<Sidebar collapsible="icon" className="absolute h-full">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="North hub" className="rounded-md border-transparent">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<CpuIcon className="size-4" />
</div>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-display font-medium text-sidebar-foreground">North hub</span>
<span className="truncate font-mono text-[10px] tracking-[0.12em] uppercase">Computer 42</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Hub</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={active === item.title}
onClick={() => setActive(item.title)}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Settings"
isActive={active === "Settings"}
onClick={() => setActive("Settings")}
>
<SettingsIcon />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1 data-[orientation=vertical]:h-4" />
<span className="truncate font-mono text-[10px] tracking-[0.2em] text-foreground uppercase">{active}</span>
</header>
<div className="grid flex-1 content-start gap-3 p-4">
<p className="max-w-sm text-sm text-muted-foreground">
Collapsed to icons. Hover an icon to see its name, or press the toggle to expand.
</p>
{["Computer 17", "Computer 42", "Computer 58"].map((name, i) => (
<div key={name} className="flex items-center justify-between rounded-lg border bg-card px-4 py-3">
<span className="font-display text-sm font-medium">{name}</span>
<span className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">
{i === 1 ? "Busy" : "Online"}
</span>
</div>
))}
</div>
</SidebarInset>
</SidebarProvider>
</div>
);
}Floating
"use client";
import * as React from "react";
import { BookOpenIcon, CpuIcon, LayoutDashboardIcon, ServerIcon, SettingsIcon } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
const items = [
{ title: "Overview", icon: LayoutDashboardIcon },
{ title: "Servers", icon: ServerIcon },
{ title: "Turtles", icon: CpuIcon },
{ title: "Guestbook", icon: BookOpenIcon },
{ title: "Settings", icon: SettingsIcon },
];
export default function SidebarFloating() {
const [active, setActive] = React.useState("Guestbook");
return (
<div className="h-[460px] w-full overflow-hidden rounded-lg border bg-background">
<SidebarProvider keyboardShortcut={false} className="relative h-full min-h-0">
<Sidebar variant="floating" className="absolute h-full">
<SidebarHeader className="px-4 pt-4">
<div className="font-display text-base font-medium tracking-tight">Lantern hub</div>
<div className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">Floating sidebar</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Pages</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton isActive={active === item.title} onClick={() => setActive(item.title)}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Syncing</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{[0, 1, 2].map((i) => (
<SidebarMenuItem key={i}>
<SidebarMenuSkeleton showIcon />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1 data-[orientation=vertical]:h-4" />
<span className="truncate font-mono text-[10px] tracking-[0.2em] text-foreground uppercase">{active}</span>
</header>
<div className="grid flex-1 content-start gap-3 p-4 pt-1">
{["Thanks for the relay tips. - miner-02", "Hub looks great from spawn.", "Left some coal in the chest."].map(
(entry) => (
<div key={entry} className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">
{entry}
</div>
),
)}
</div>
</SidebarInset>
</SidebarProvider>
</div>
);
}