Sidebar
BetaBeta — the props contract is close to final, but small breaking changes can still land.Responsive app sidebar with resizing, offcanvas and icon collapse, mobile navigation, and nested menus.
Examples
Mobile bottom navigation
Below lg, SidebarMobileNav shows four primary destinations and Menu. Define destinations, icons, availability, and active state once, then render the same items with SidebarMenuButton or SidebarMobileNavItem. Sidebar mobileVariant="drawer" reuses the complete navigation in a bottom drawer, including with collapsible="none". Keep at most five visible controls, counting Menu. Desktop navigation stays in the sidebar.
Floating mobile menu
Use SidebarMobileNav variant="floating" with SidebarMobileTrigger when a persistent destination bar is not useful. The labelled Menu button opens the same complete navigation. Both presentations share SidebarProvider, support safe areas, reserve content space, and stay inside a layout="contained" preview. Keep the navigation definition and route matching in your application; use render for your router links.
Resizable sidebar
Add SidebarRail with resizable to drag or use arrow keys to resize. Drag below minWidth to collapse; click or press Enter to toggle. SidebarProvider accepts defaultWidth or width/onWidthChange in pixels. Keep saved preferences in your application.
Right sidebar
Use side="right" for navigation on the other edge. The rail follows that edge for pointer and keyboard resizing. This example controls width through SidebarProvider.
Nested navigation
Compose Collapsible with SidebarMenuButton and SidebarMenuSub for project subpages. Group labels also render CollapsibleTrigger to fold entire categories. Nested menus hide when the desktop sidebar collapses to icons.
Floating sidebar
Use variant="floating" to give the navigation its own bordered surface. On mobile it opens as an edge sheet with the same navigation.
Inset workspace
Use variant="inset" with SidebarInset to frame the content inside the workspace. Each preview contains one complete app shell; use its trigger to collapse the sidebar or open it on mobile.
Composition
Shared mobile navigation
Declare destinations and their behavior once in the application. Render the same items for the desktop sidebar and mobile primary destinations; Menu opens the existing sidebar content. The provider shares visibility, while the application owns route selection.
- <SidebarProvider>
- <Sidebar>
- mobileVariant="drawer"; complete navigation
- <SidebarContent>
- <SidebarMenu />
- <SidebarContent>
- mobileVariant="drawer"; complete navigation
- <SidebarInset>
- route content
- <SidebarMobileNav>
- <SidebarMobileNavItem />
- <SidebarMobileTrigger />
- <Sidebar>
Application shell
- <SidebarProvider>
- <Sidebar>
- <SidebarHeader />
- <SidebarContent>
- <SidebarGroup>
- <SidebarGroupLabel />
- <SidebarGroupContent>
- <SidebarMenu>
- <SidebarMenuItem>
- <SidebarMenuButton />
- <SidebarMenuBadge />
- <SidebarMenuAction />
- <SidebarMenuSub>
- <SidebarMenuItem>
- <SidebarMenuButton />
- <SidebarMenuItem>
- <SidebarMenuItem>
- <SidebarMenu>
- <SidebarGroup>
- <SidebarFooter />
- <SidebarRail />
- <SidebarInset>
- <SidebarTrigger />
- <Sidebar>
Collapsible navigation group
- <SidebarProvider>
- <Sidebar>
- <SidebarContent>
- <SidebarGroup>
- <SidebarMenu>
- <SidebarMenuItem>
- <Collapsible>
- <SidebarMenuButton>
- render prop
- <CollapsibleTrigger />
- render prop
- <CollapsibleContent>
- <SidebarMenuSub>
- <SidebarMenuItem>
- <SidebarMenuButton />
- <SidebarMenuItem>
- <SidebarMenuSub>
- <SidebarMenuButton>
- <Collapsible>
- <SidebarMenuItem>
- <SidebarMenu>
- <SidebarGroup>
- <SidebarContent>
- <Sidebar>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/sidebar.tsx.
npx shadcn@latest add https://control-ui.dev/r/sidebar.jsonDependencies
src/registry/hooks/use-mobile.tsHooksrc/registry/sources/control-ui/ui/sidebar-menu.tsxSupportsrc/registry/sources/control-ui/ui/sidebar-mobile.tsxSupportsrc/registry/sources/control-ui/ui/sidebar-provider.tsxSupportsrc/registry/sources/control-ui/ui/sidebar-resize-rail.tsxSupportsrc/registry/sources/control-ui/ui/sheet.tsxSkinbuttonPrimitiveDrawerdrawerPrimitiveScroll areascroll-areaPrimitiveTooltiptooltipPrimitiveTrack highlighttrack-highlightPrimitiveRaw code
Primary installed source
"use client";import { useRender } from "@base-ui/react/use-render";import { PanelLeftIcon } from "lucide-react";import type { ComponentProps, CSSProperties, MouseEvent, Ref } from "react";import { useState } from "react";import { createPortal } from "react-dom";import type { RenderProp } from "@/components/control-ui/control-props";import type { SidebarKnobStyle } from "@/components/control-ui/knob-contracts/sidebar-knobs";import { cn } from "@/components/control-ui/lib/cn";import type { SidebarLayout } from "@/components/control-ui/skin";import { useSkin } from "@/components/control-ui/skin-provider";import { Button } from "@/components/control-ui/ui/button";import { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from "@/components/control-ui/ui/drawer";import { ScrollArea } from "@/components/control-ui/ui/scroll-area";import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/control-ui/ui/sheet";import { type SidebarStyle, SidebarSurfaceContext, useSidebar, useSidebarElements, useSidebarSurface,} from "@/components/control-ui/ui/sidebar-provider";import { SidebarResizeRail, type SidebarResizeRailProps } from "@/components/control-ui/ui/sidebar-resize-rail";// biome-ignore lint/performance/noBarrelFile: Preserve the sidebar install-facing API.export { SidebarGroup, SidebarGroupContent, type SidebarGroupContentProps, SidebarGroupLabel, type SidebarGroupLabelProps, SidebarMenu, SidebarMenuAction, type SidebarMenuActionProps, SidebarMenuBadge, type SidebarMenuBadgeProps, SidebarMenuButton, type SidebarMenuButtonProps, type SidebarMenuButtonSize, type SidebarMenuButtonVariant, SidebarMenuItem, SidebarMenuSub, type SidebarSelectionIndicator, sidebarMenuButtonSizes, sidebarMenuButtonVariants,} from "@/components/control-ui/ui/sidebar-menu";export { SidebarMobileNav, SidebarMobileNavItem, type SidebarMobileNavProps, SidebarMobileTrigger, type SidebarMobileTriggerProps,} from "@/components/control-ui/ui/sidebar-mobile";export { type SidebarLayoutMode, SidebarProvider, type SidebarProviderProps, type SidebarStyle, useSidebar,} from "@/components/control-ui/ui/sidebar-provider";export type SidebarRailProps = | (Omit<ComponentProps<"button">, "style"> & { resizable?: false; style?: CSSProperties & SidebarKnobStyle }) | (SidebarResizeRailProps & { resizable: true });export type SidebarInsetProps = Omit<ComponentProps<"main">, "style" | "id"> & { render?: RenderProp<ComponentProps<"main">>; style?: CSSProperties & SidebarKnobStyle;};type SidebarSurfaceStyle = CSSProperties & SidebarKnobStyle;const SIDEBAR_WIDTH_MOBILE = "18rem";const sidebarTriggerWidth = { xs: "w-[var(--control-h-xs)]", sm: "w-[var(--control-h-sm)]", md: "w-[var(--control-h-md)]", lg: "w-[var(--control-h-lg)]",} satisfies Record<NonNullable<ComponentProps<typeof Button>["size"]>, string>;export type SidebarProps = Omit<ComponentProps<"div">, "style"> & { side?: "left" | "right"; variant?: SidebarLayout; collapsible?: "offcanvas" | "icon" | "none"; mobileVariant?: "sheet" | "drawer"; label?: string; style?: SidebarSurfaceStyle;};export function Sidebar({ side = "left", collapsible = "offcanvas", ...props }: SidebarProps) { const [railContainer, setRailContainer] = useState<HTMLDivElement | null>(null); return ( <SidebarSurfaceContext.Provider value={{ side, collapsible, railContainer }}> <SidebarSurface {...props} railContainerRef={setRailContainer} /> </SidebarSurfaceContext.Provider> );}function SidebarSurface({ variant, mobileVariant = "sheet", label = "Navigation", ref, className, children, style, railContainerRef, ...props}: SidebarProps & { railContainerRef: Ref<HTMLDivElement> }) { const { side, collapsible } = useSidebarSurface(); const { offcanvasRef } = useSidebarElements(); const skin = useSkin(); const { isMobile, state, layout, sidebarId } = useSidebar(); const resolvedVariant = variant ?? skin.sidebarLayout ?? "sidebar"; const padded = resolvedVariant === "floating" || resolvedVariant === "inset"; const container = useRender({ defaultTagName: "div", ref: collapsible === "offcanvas" ? [ref ?? null, offcanvasRef] : ref, props: { id: sidebarId, ...props, "data-control-ui": "sidebar", "data-control-family": "sidebar", "data-slot": "container", tabIndex: -1, className: cn( layout === "contained" ? "absolute h-full" : "fixed h-svh", "inset-y-0 z-10 hidden w-(--sidebar-width) lg:flex", side === "left" ? "start-0" : "end-0", padded ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", className, ), children: ( <> <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="inner" className="flex h-full w-full flex-col" inert={state === "collapsed" && collapsible === "offcanvas"} > {children} </div> <div ref={railContainerRef} className="contents" /> </> ), }, }); if (isMobile && (collapsible !== "none" || mobileVariant === "drawer")) { return ( <SidebarMobileSurface {...props} ref={ref} mobileVariant={mobileVariant} label={label} variant={resolvedVariant} className={className} style={style} > {children} </SidebarMobileSurface> ); } if (collapsible === "none") { return ( <div ref={ref} data-control-ui="sidebar" data-control-family="sidebar" data-slot="root" data-surface="panel" data-variant={resolvedVariant} data-side={side} className={cn("group relative flex h-full w-(--sidebar-width) flex-col", className)} style={style} {...props} > {children} <div ref={railContainerRef} className="contents" /> </div> ); } return ( <div className={cn("group peer hidden lg:block", side === "right" && "order-last")} data-control-ui="sidebar" data-control-family="sidebar" data-slot="root" data-surface="panel" data-state={state} data-collapsible={state === "collapsed" ? collapsible : ""} data-variant={resolvedVariant} data-side={side} style={style} > <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="gap" className={cn( "relative w-(--sidebar-width)", "group-data-[collapsible=offcanvas]:w-0", padded ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", )} /> {container} </div> );}function SidebarMobileSurface({ mobileVariant, label, variant, ref, className, children, style, ...props }: SidebarProps) { const { side } = useSidebarSurface(); const { openMobile, setOpenMobile, sidebarId } = useSidebar(); const { activeTriggerRef, triggerRef, insetRef } = useSidebarElements(); function finalFocus() { const trigger = activeTriggerRef.current ?? triggerRef.current; return trigger?.isConnected && trigger.checkVisibility() ? trigger : insetRef.current; } const mobileContent = ( <div ref={ref} id={sidebarId} data-control-ui="sidebar" data-control-family="sidebar" data-slot="root" data-surface="panel" data-variant={variant} data-mobile="" data-side={side} className={cn("flex min-h-0 flex-1 flex-col", className)} style={style} {...props} > <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="inner" className="flex min-h-0 flex-1 flex-col"> {children} </div> </div> ); if (mobileVariant === "drawer") { return ( <Drawer open={openMobile} onOpenChange={setOpenMobile}> <DrawerContent side="bottom" finalFocus={finalFocus} className="max-h-[85dvh]"> <DrawerHeader className="sr-only"> <DrawerTitle>{label}</DrawerTitle> </DrawerHeader> <DrawerBody padding="none">{mobileContent}</DrawerBody> <DrawerFooter> <DrawerClose render={<Button variant="surface" size="lg" />}>Close menu</DrawerClose> </DrawerFooter> </DrawerContent> </Drawer> ); } const mobileSheetStyle: SidebarStyle = { "--sidebar-width": SIDEBAR_WIDTH_MOBILE, ...style }; return ( <Sheet open={openMobile} onOpenChange={setOpenMobile}> <SheetContent side={side} finalFocus={finalFocus} className="w-(--sidebar-width) gap-0 p-0" style={mobileSheetStyle}> <SheetHeader className="sr-only"> <SheetTitle>{label}</SheetTitle> </SheetHeader> {mobileContent} </SheetContent> </Sheet> );}export function SidebarTrigger({ className, onClick, size = "sm", label = "Toggle sidebar", ref, ...props}: ComponentProps<typeof Button> & { label?: string }) { const { toggleSidebar, isMobile, openMobile, open, keyboardShortcut, sidebarId } = useSidebar(); const { triggerRef, activeTriggerRef, railRef } = useSidebarElements(); const shortcutKey = keyboardShortcut?.toUpperCase(); return useRender({ defaultTagName: "button", render: <Button variant="ghost" size={size} />, ref: [ref ?? null, triggerRef], props: { ...props, "data-control-ui": "sidebar", "data-slot": "trigger", "data-sidebar-trigger": "", "aria-expanded": isMobile ? openMobile : open, "aria-controls": isMobile && !openMobile ? undefined : sidebarId, "aria-keyshortcuts": shortcutKey ? `Meta+${shortcutKey} Control+${shortcutKey}` : undefined, className: cn(sidebarTriggerWidth[size], "px-0", className), onClick: (event: MouseEvent<HTMLButtonElement>) => { onClick?.(event); if (event.defaultPrevented) return; activeTriggerRef.current = event.currentTarget; toggleSidebar(); if (isMobile || open) return; const trigger = event.currentTarget; requestAnimationFrame(() => { if (!trigger.checkVisibility()) railRef.current?.focus({ preventScroll: true }); }); }, children: ( <> <PanelLeftIcon className="size-4" data-icon-dir="inline" aria-hidden="true" /> <span className="sr-only">{label}</span> </> ), }, });}export function SidebarRail(props: SidebarRailProps) { const { isMobile } = useSidebar(); const { railContainer } = useSidebarSurface(); if (isMobile || !railContainer) return null; if (props.resizable) { const { resizable, ...resizeProps } = props; return createPortal(<SidebarResizeRail {...resizeProps} />, railContainer); } const { resizable, ...toggleProps } = props; return createPortal(<SidebarToggleRail {...toggleProps} />, railContainer);}function SidebarToggleRail({ className, ref, onClick, ...props}: Omit<ComponentProps<"button">, "style"> & { style?: CSSProperties & SidebarKnobStyle }) { const { toggleSidebar, open } = useSidebar(); const { railRef } = useSidebarElements(); return useRender({ defaultTagName: "button", ref: [ref ?? null, railRef], props: { ...props, type: "button", "data-control-ui": "sidebar", "data-control-family": "sidebar", "data-slot": "rail", "aria-label": props["aria-label"] ?? "Toggle sidebar", "aria-expanded": open, tabIndex: -1, onClick: (event: MouseEvent<HTMLButtonElement>) => { onClick?.(event); if (!event.defaultPrevented) toggleSidebar(); }, title: props.title ?? "Toggle sidebar", className: cn( "absolute inset-y-0 z-20 hidden -translate-x-1/2 rtl:translate-x-1/2 group-data-[side=left]:-end-4 group-data-[side=right]:start-0 lg: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", className, ), }, });}export function SidebarInset({ className, render, ref, ...props }: SidebarInsetProps) { const { layout, contentId } = useSidebar(); const { insetRef } = useSidebarElements(); return useRender({ defaultTagName: layout === "contained" ? "div" : "main", render, ref: [ref ?? null, insetRef], props: { tabIndex: -1, ...props, id: contentId, "data-control-ui": "sidebar", "data-control-family": "sidebar", "data-slot": "inset", className: cn("relative flex min-h-0 min-w-0 w-full flex-1 flex-col", className), }, });}export function SidebarHeader({ className, ...props }: ComponentProps<"div"> & { style?: CSSProperties & SidebarKnobStyle }) { return ( <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="header" className={cn("flex flex-col", className)} {...props} /> );}export function SidebarFooter({ className, ...props }: ComponentProps<"div"> & { style?: CSSProperties & SidebarKnobStyle }) { return ( <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="footer" className={cn("flex flex-col", className)} {...props} /> );}export function SidebarContent({ className, children, ...props }: ComponentProps<"div">) { const { state, isMobile } = useSidebar(); const { collapsible } = useSidebarSurface(); const iconOnly = !isMobile && state === "collapsed" && collapsible === "icon"; return ( <ScrollArea data-control-ui="sidebar" data-slot="content" className={cn("min-h-0 flex-1 group-data-[collapsible=icon]:overflow-hidden", className)} lockAxis="x" scrollbarGutter={iconOnly ? "auto" : undefined} viewportClassName="overscroll-y-contain" {...props} > <div data-control-ui="sidebar" data-control-family="sidebar" data-slot="content-stack" className="flex flex-col"> {children} </div> </ScrollArea> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-sidebar-* · 59 knobsHow the cascade resolves--cui-sidebar-nav-height<length-percentage>4.5rem--cui-sidebar-nav-background<color>var(--sidebar)--cui-sidebar-nav-foreground<color>var(--sidebar-foreground)--cui-sidebar-nav-border-color<color>var(--sidebar-border)--cui-sidebar-nav-border-width<length>var(--control-rim-width)--cui-sidebar-nav-shadow*var(--shadow-md)--cui-sidebar-nav-item-radius<length-percentage>var(--radius-control)--cui-sidebar-nav-item-active-background<color>var(--active-fill)--cui-sidebar-nav-item-active-foreground<color>var(--foreground)--cui-sidebar-nav-gap<length-percentage>calc(var(--spacing) * 3)--cui-sidebar-menu-badge-height<length-percentage>calc(var(--spacing) * 5)--cui-sidebar-menu-badge-background<color>oklch(from var(--sidebar-foreground) l c h / 0.08)--cui-sidebar-menu-badge-foreground<color>var(--sidebar-foreground)--cui-sidebar-rail-divider-background<color>transparent--cui-sidebar-wrapper-background<color>transparent--cui-sidebar-wrapper-background-image*none--cui-sidebar-inner-background<color>var(--sidebar)--cui-sidebar-inner-border-color<color>var(--sidebar-border)--cui-sidebar-inner-background-image*none--cui-sidebar-inner-backdrop-filter*none--cui-sidebar-inner-border-width<length>0px--cui-sidebar-inner-radius<length-percentage>0px--cui-sidebar-inner-shadow*none--cui-sidebar-header-background<color>transparent--cui-sidebar-header-shadow*0 0 transparent--cui-sidebar-inset-background<color>var(--background)--cui-sidebar-inset-background-image*none--cui-sidebar-inset-shadow*var(--shadow-sm)--cui-sidebar-inset-border-color<color>var(--sidebar-border)--cui-sidebar-inset-border-width<length>0px--cui-sidebar-inset-radius<length-percentage>var(--radius-xl)--cui-sidebar-inset-inset<length-percentage>calc(var(--spacing) * 2)--cui-sidebar-menu-button-radius<length-percentage>var(--radius-popup-item)--cui-sidebar-group-label-foreground<color>oklch(from var(--sidebar-foreground) l c h / 0.7)--cui-sidebar-menu-button-foreground<color>var(--sidebar-foreground)--cui-sidebar-menu-button-hover-background<color>var(--hover-fill)--cui-sidebar-menu-button-hover-foreground<color>var(--foreground)--cui-sidebar-menu-button-hover-shadow*0 0 transparent--cui-sidebar-menu-button-active-background<color>var(--active-fill)--cui-sidebar-menu-button-active-foreground<color>var(--foreground)--cui-sidebar-menu-button-active-shadow*0 0 transparent--cui-sidebar-menu-button-track-active-foreground<color>var(--foreground)--cui-sidebar-menu-button-height<length-percentage>var(--control-h-md)--cui-sidebar-menu-button-padding-block<length-percentage>0px--cui-sidebar-menu-button-padding-inline<length-percentage>calc(var(--sidebar-width-icon) / 2 - var(--spacing) * 4)--cui-sidebar-menu-button-font-size<length-percentage>var(--text-body)--cui-sidebar-menu-button-font-weight<number>400--cui-sidebar-menu-button-active-font-weight<number>500--cui-sidebar-group-label-font-size<length-percentage>var(--text-caption)--cui-sidebar-group-label-font-weight<number>500--cui-sidebar-group-label-letter-spacing<length>0em--cui-sidebar-group-label-text-transformnone | uppercase | lowercase | capitalizenone--cui-sidebar-group-label-padding<length-percentage>+0px calc(var(--spacing) * 2)--cui-sidebar-skip-link-background<color>var(--background)--cui-sidebar-skip-link-foreground<color>var(--foreground)--cui-sidebar-skip-link-radius<length-percentage>var(--radius-control)--cui-sidebar-skip-link-shadow*var(--shadow-md)--cui-sidebar-menu-button-background<color>transparent--cui-sidebar-menu-button-shadow*0 0 transparent