rapidUI

Sidebar

Composable, collapsible application sidebar with mobile sheet support.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/sidebar.json

Installs to @/components/ui/sidebar.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 class-variance-authority@^0.7.1 lucide-react@^1.48.0

Add the components it builds on: button, input, separator, sheet, skeleton, tooltip.

Copy the source into your project:

components/ui/sidebar.tsx
'use client'

import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
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, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'

const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3.5rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'

type SidebarContextProps = {
	state: 'expanded' | 'collapsed'
	open: boolean
	setOpen: (open: boolean) => void
	openMobile: boolean
	setOpenMobile: (open: 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,
	className,
	style,
	children,
	...props
}: React.ComponentProps<'div'> & {
	defaultOpen?: boolean
	open?: boolean
	onOpenChange?: (open: boolean) => void
}) {
	const isMobile = useIsMobile()
	const [openMobile, setOpenMobile] = React.useState(false)

	// This is the internal state of the sidebar.
	// We use openProp and setOpenProp for control from outside the component.
	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)
			}

			// This sets the cookie to keep the sidebar state.
			document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
		},
		[setOpenProp, open],
	)

	// Helper to toggle the sidebar.
	const toggleSidebar = React.useCallback(() => {
		return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
	}, [isMobile, setOpen, setOpenMobile])

	// Adds a keyboard shortcut to toggle the sidebar.
	React.useEffect(() => {
		const handleKeyDown = (event: KeyboardEvent) => {
			if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
				event.preventDefault()
				toggleSidebar()
			}
		}

		globalThis.addEventListener('keydown', handleKeyDown)
		return () => globalThis.removeEventListener('keydown', handleKeyDown)
	}, [toggleSidebar])

	// We add a state so that we can do data-state="expanded" or "collapsed".
	// This makes it easier to style the sidebar with Tailwind classes.
	const state = open ? 'expanded' : 'collapsed'

	const contextValue = React.useMemo<SidebarContextProps>(
		() => ({
			state,
			open,
			setOpen,
			isMobile,
			openMobile,
			setOpenMobile,
			toggleSidebar,
		}),
		[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
	)

	return (
		<SidebarContext.Provider value={contextValue}>
			<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>
		</SidebarContext.Provider>
	)
}

function Sidebar({
	side = 'left',
	variant = 'sidebar',
	collapsible = 'offcanvas',
	className,
	children,
	dir,
	...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
					dir={dir}
					data-sidebar="sidebar"
					data-slot="sidebar"
					data-mobile="true"
					className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
					style={
						{
							'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
						} as React.CSSProperties
					}
					side={side}
				>
					<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"
		>
			{/* This is what handles the sidebar gap on desktop */}
			<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"
				data-side={side}
				className={cn(
					'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
					// Adjust the padding for floating and inset variants.
					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',
					className,
				)}
				{...props}
			>
				<div
					data-sidebar="sidebar"
					data-slot="sidebar-inner"
					className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
				>
					{children}
				</div>
			</div>
		</div>
	)
}

/**
 * Collapses and expands the sidebar.
 *
 * Pass an `aria-label`: this package carries no translations, so the accessible
 * name has to come from the app. The built-in "Toggle Sidebar" text is only an
 * English fallback.
 */
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
	const { toggleSidebar } = useSidebar()

	return (
		<Button
			data-sidebar="trigger"
			data-slot="sidebar-trigger"
			variant="ghost"
			size="icon-sm"
			className={cn(className)}
			onClick={(event) => {
				onClick?.(event)
				toggleSidebar()
			}}
			{...props}
		>
			<PanelLeftIcon aria-hidden="true" />
			<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 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
				'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 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-xl md:peer-data-[variant=inset]:shadow-sm 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-10 w-full bg-background shadow-none', 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', className)}
			{...props}
		/>
	)
}

function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
	return (
		<div
			data-slot="sidebar-content"
			data-sidebar="content"
			className={cn(
				'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto 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,
	render,
	...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
	return useRender({
		defaultTagName: 'div',
		props: mergeProps<'div'>(
			{
				className: cn(
					'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
					className,
				),
			},
			props,
		),
		render,
		state: {
			slot: 'sidebar-group-label',
			sidebar: 'group-label',
		},
	})
}

function SidebarGroupAction({
	className,
	render,
	...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
	return useRender({
		defaultTagName: 'button',
		props: mergeProps<'button'>(
			{
				className: cn(
					'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
					className,
				),
			},
			props,
		),
		render,
		state: {
			slot: 'sidebar-group-action',
			sidebar: 'group-action',
		},
	})
}

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-0', 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 group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-10! group-data-[collapsible=icon]:gap-3! group-data-[collapsible=icon]:p-3! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
	{
		variants: {
			variant: {
				default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
				outline:
					'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
			},
			size: {
				default: 'h-10 text-sm',
				sm: 'h-8 text-xs',
				lg: 'h-12 text-sm group-data-[collapsible=icon]:p-1!',
			},
		},
		defaultVariants: {
			variant: 'default',
			size: 'default',
		},
	},
)

function SidebarMenuButton({
	render,
	isActive = false,
	variant = 'default',
	size = 'default',
	tooltip,
	className,
	...props
}: useRender.ComponentProps<'button'> &
	React.ComponentProps<'button'> & {
		isActive?: boolean
		tooltip?: string | React.ComponentProps<typeof TooltipContent>
	} & VariantProps<typeof sidebarMenuButtonVariants>) {
	const { isMobile, state } = useSidebar()
	const comp = useRender({
		defaultTagName: 'button',
		props: mergeProps<'button'>(
			{
				className: cn(sidebarMenuButtonVariants({ variant, size }), className),
			},
			props,
		),
		render: tooltip ? <TooltipTrigger render={render} /> : render,
		state: {
			slot: 'sidebar-menu-button',
			sidebar: 'menu-button',
			size,
			active: isActive,
		},
	})

	if (!tooltip) {
		return comp
	}

	if (typeof tooltip === 'string') {
		tooltip = {
			children: tooltip,
		}
	}

	return (
		<Tooltip>
			{comp}
			<TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} />
		</Tooltip>
	)
}

function SidebarMenuAction({
	className,
	render,
	showOnHover = false,
	...props
}: useRender.ComponentProps<'button'> &
	React.ComponentProps<'button'> & {
		showOnHover?: boolean
	}) {
	return useRender({
		defaultTagName: 'button',
		props: mergeProps<'button'>(
			{
				className: cn(
					'absolute top-2.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2.5 peer-data-[size=lg]/menu-button:top-3.5 peer-data-[size=sm]/menu-button:top-1.5 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
					showOnHover &&
						'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
					className,
				),
			},
			props,
		),
		render,
		state: {
			slot: 'sidebar-menu-action',
			sidebar: 'menu-action',
		},
	})
}

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 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2.5 peer-data-[size=lg]/menu-button:top-3.5 peer-data-[size=sm]/menu-button:top-1.5 peer-data-active/menu-button:text-sidebar-accent-foreground',
				className,
			)}
			{...props}
		/>
	)
}

function SidebarMenuSkeleton({
	className,
	showIcon = false,
	...props
}: React.ComponentProps<'div'> & {
	showIcon?: boolean
}) {
	// Random width between 50 to 90%.
	const [width] = React.useState(() => {
		return `${Math.floor(Math.random() * 40) + 50}%`
	})

	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-md" data-sidebar="menu-skeleton-icon" />}
			<Skeleton
				className="h-4 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-1 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({
	render,
	size = 'md',
	isActive = false,
	className,
	...props
}: useRender.ComponentProps<'a'> &
	React.ComponentProps<'a'> & {
		size?: 'sm' | 'md'
		isActive?: boolean
	}) {
	return useRender({
		defaultTagName: 'a',
		props: mergeProps<'a'>(
			{
				className: cn(
					'flex h-8 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
					className,
				),
			},
			props,
		),
		render,
		state: {
			slot: 'sidebar-menu-sub-button',
			sidebar: 'menu-sub-button',
			size,
			active: isActive,
		},
	})
}

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.ts
import * as React from 'react'

const MOBILE_BREAKPOINT = 768

export function useIsMobile() {
	const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

	React.useEffect(() => {
		const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
		const onChange = () => {
			setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
		}
		mql.addEventListener('change', onChange)
		setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
		return () => mql.removeEventListener('change', onChange)
	}, [])

	return !!isMobile
}

A comprehensive sidebar component with collapsible behavior, multiple variants, and full mobile support. Built with accessibility and responsive design in mind using composable primitives.

Features

  • Multiple variants: sidebar, floating, and inset
  • Collapsible behavior: icon, offcanvas, or none
  • Mobile-responsive with sheet overlay
  • Left and right positioning support
  • Nested menu structures with sub-items
  • Group organization with labels and actions
  • Loading states with skeleton components
  • Tooltip support for collapsed states (requires a TooltipProvider ancestor)
  • Keyboard shortcut: ⌘B / Ctrl+B toggles the sidebar
  • SidebarTrigger icon button (ghost, icon-sm) with a built-in "Toggle Sidebar" screen-reader label
  • Keyboard navigation and accessibility
  • Cookie-based state persistence
  • Customizable styling and theming
  • Built on top of Base UI primitives

Usage

import {
  Sidebar,
  SidebarProvider,
  SidebarTrigger,
  SidebarHeader,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarGroupContent,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
  SidebarMenuAction,
  SidebarMenuBadge,
  SidebarMenuSub,
  SidebarMenuSubItem,
  SidebarMenuSubButton,
  SidebarSeparator,
  SidebarInput,
  SidebarInset,
  useSidebar,
} from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip"

// Basic sidebar setup (TooltipProvider is needed for SidebarMenuButton tooltips;
// usually it is rendered once at the app root)
<TooltipProvider>
  <SidebarProvider>
    <Sidebar collapsible="icon">
      <SidebarHeader>
        <SidebarInput aria-label="Search cases" placeholder="Search cases…" />
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupLabel>Workspace</SidebarGroupLabel>
          <SidebarGroupContent>
            <SidebarMenu>
              <SidebarMenuItem>
                <SidebarMenuButton tooltip="My cases" isActive>
                  <FolderOpen />
                  <span>My cases</span>
                </SidebarMenuButton>
              </SidebarMenuItem>
              <SidebarMenuItem>
                <SidebarMenuButton tooltip="Inbox">
                  <Inbox />
                  <span>Inbox</span>
                </SidebarMenuButton>
              </SidebarMenuItem>
            </SidebarMenu>
          </SidebarGroupContent>
        </SidebarGroup>
      </SidebarContent>
      <SidebarFooter>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton>
              <User />
              <span>Sofia Berg</span>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarFooter>
    </Sidebar>
    <SidebarInset>
      <header className="flex h-12 items-center gap-2 border-b px-4">
        <SidebarTrigger className="-ml-1" />
        <span>My cases</span>
      </header>
      <div className="flex-1 p-4">Your main content here</div>
    </SidebarInset>
  </SidebarProvider>
</TooltipProvider>

// With nested menu
<SidebarMenu>
  <SidebarMenuItem>
    <SidebarMenuButton>
      <Building2 />
      <span>Building and planning</span>
      <ChevronRight className="ml-auto" />
    </SidebarMenuButton>
    <SidebarMenuSub>
      <SidebarMenuSubItem>
        <SidebarMenuSubButton href="/building-permits" isActive>
          <span>Building permits</span>
        </SidebarMenuSubButton>
      </SidebarMenuSubItem>
      <SidebarMenuSubItem>
        <SidebarMenuSubButton href="/detailed-plans">
          <span>Detailed plans</span>
        </SidebarMenuSubButton>
      </SidebarMenuSubItem>
    </SidebarMenuSub>
  </SidebarMenuItem>
</SidebarMenu>

// With actions and badges
<SidebarMenuItem>
  <SidebarMenuButton>
    <Inbox />
    <span>Messages</span>
  </SidebarMenuButton>
  <SidebarMenuAction showOnHover>
    <Plus />
    <span className="sr-only">New message</span>
  </SidebarMenuAction>
  <SidebarMenuBadge>12</SidebarMenuBadge>
</SidebarMenuItem>

// As a link (use your router's Link the same way)
<SidebarMenuItem>
  <SidebarMenuButton render={<a href="/cases" />} isActive>
    <FolderOpen />
    <span>My cases</span>
  </SidebarMenuButton>
</SidebarMenuItem>

// Using the sidebar hook
function MyComponent() {
  const { state, toggleSidebar, isMobile } = useSidebar()
  
  return (
    <button onClick={toggleSidebar}>
      Sidebar is {state}
    </button>
  )
}

Examples

Default

A case management app: an organisation header, quick links with a notification badge, grouped navigation and the signed-in handler in the footer. It collapses to icons (collapsible="icon"), with tooltips on every item and a SidebarRail to drag or click.

Basic

The smallest useful sidebar: a header and one menu with an active item.

With Groups

A search field in the header and labelled groups divided by SidebarSeparator.

With Nested Menu

Service areas that expand into sub-menus, built with <Collapsible render={<SidebarMenuItem />}> and <CollapsibleTrigger render={<SidebarMenuButton />}>.

With Actions

A SidebarGroupAction to add a saved search, and a SidebarMenuAction per item (shown on hover) that opens a dropdown menu.

With Organisation Switcher

The organisation header as a DropdownMenuTrigger. The menu takes the width of the trigger while the sidebar is expanded. Collapsed to icons, the trigger is a 40px button with the 32px logo centred, and the menu is as wide as its content.

For a list of organisations to pick from, with sub-organisations and search, use the OrganizationSwitcher component with render={<SidebarMenuButton size="lg" />} on its trigger instead of building the menu by hand.

Floating Variant

variant="floating" gives the sidebar rounded corners, a border and a shadow.

Inset Variant

variant="inset" places the main content in a rounded panel next to the sidebar.

Loading State

SidebarMenuSkeleton placeholders while the menu loads.

side="right" for a details panel – here the metadata and documents of the open case.

Non-Collapsible

collapsible="none" renders a fixed-width sidebar that can't be collapsed.

Offcanvas

collapsible="offcanvas" slides the sidebar completely off-screen when collapsed.

SidebarMenuButton sizes (sm, default, lg with a 32px icon tile), variants (default, outline), the active state, and SidebarMenuSubButton sizes (md, sm).

Props

SidebarProvider

  • defaultOpen?: boolean - Default open state (default: true)
  • open?: boolean - Controlled open state
  • onOpenChange?: (open: boolean) => void - Callback when open state changes
  • Extends standard div props
  • side?: "left" | "right" - Position of the sidebar (default: "left")
  • variant?: "sidebar" | "floating" | "inset" - Visual variant (default: "sidebar")
  • collapsible?: "offcanvas" | "icon" | "none" - Collapse behavior (default: "offcanvas")
  • dir?: "ltr" | "rtl" - Text direction, forwarded to the mobile sheet
  • Extends standard div props

SidebarTrigger

  • Ghost icon button (size="icon-sm") with a PanelLeft icon and an sr-only "Toggle Sidebar" label
  • Toggles the sidebar on click (your onClick runs first)
  • Place it where it stays visible when the sidebar is collapsed, typically in the SidebarInset header
  • Extends Button props

SidebarRail

  • A thin hit area on the sidebar edge that toggles the sidebar on click

SidebarMenuButton

  • render?: React.ReactElement | function - Render as another element, e.g. render={<a href="/cases" />}
  • isActive?: boolean - Whether the menu item is active
  • variant?: "default" | "outline" - Visual variant
  • size?: "default" | "sm" | "lg" - Button size. lg is made for a 32px logo or avatar followed by two lines of text, as in the header and footer; collapsed to icons it centres that 32px element in the 40px button
  • tooltip?: string | TooltipContentProps - Tooltip shown only when the sidebar is collapsed to icons (not on mobile)
  • Extends standard button props

SidebarMenuAction

  • render?: React.ReactElement | function - Render as another element, e.g. a DropdownMenuTrigger
  • showOnHover?: boolean - Only show action on hover
  • Extends standard button props

SidebarMenuSubButton

  • render?: React.ReactElement | function - Render as another element, e.g. your router's Link
  • size?: "sm" | "md" - Button size (default: "md")
  • isActive?: boolean - Whether the sub-item is active
  • Extends standard a props

SidebarMenuSkeleton

  • showIcon?: boolean - Whether to show icon skeleton
  • Extends standard div props

Variants

  • sidebar: Standard sidebar with border
  • floating: Elevated sidebar with rounded corners and shadow
  • inset: Sidebar with margin and card-like appearance

Collapsible Modes

  • icon: Collapses to icon-only width with tooltips. Menu buttons become 40px squares with the 16px icon centred and the label clipped at the button edge; size="lg" buttons keep a 32px logo or avatar centred
  • offcanvas: Slides completely off-screen
  • none: Fixed width, cannot be collapsed

State Management

useSidebar Hook

The useSidebar hook provides access to sidebar state and controls:

const {
  state,        // "expanded" | "collapsed"
  open,         // boolean - current open state
  setOpen,      // (open: boolean) => void
  openMobile,   // boolean - mobile sheet state
  setOpenMobile, // (open: boolean) => void
  isMobile,     // boolean - mobile breakpoint
  toggleSidebar // () => void - toggle function
} = useSidebar()

Persistence

The sidebar automatically saves its state to cookies (sidebar_state) with a 7-day expiration.

Keyboard Shortcut

Press ⌘B (macOS) or Ctrl+B (Windows/Linux) to toggle the sidebar.

Mobile Behavior

On mobile devices:

  • Sidebar renders as a Sheet overlay
  • Automatic responsive breakpoints
  • Touch-friendly interactions

Accessibility

The Sidebar component follows WCAG 2.1 guidelines and provides:

  • Proper ARIA roles and attributes for navigation
  • Keyboard navigation support:
    • Tab/Shift+Tab: Navigate through menu items
    • Enter/Space: Activate menu items
    • Arrow keys: Navigate nested menus
    • Escape: Close mobile sidebar
    • ⌘B / Ctrl+B: Toggle the sidebar
  • Focus management with visible indicators
  • Screen reader announcements for state changes
  • Semantic HTML structure with nav, menu, and menuitem roles
  • High contrast mode compatibility

Best Practices

  1. Structure and Organization

    • Use SidebarGroup to organize related navigation items
    • Provide clear labels with SidebarGroupLabel
    • Use separators to create visual hierarchy
    • Keep menu depth to 2-3 levels maximum
  2. Content Guidelines

    • Use meaningful icons that relate to the content
    • Keep menu labels concise and descriptive
    • Provide tooltips for collapsed state
    • Use badges sparingly for important notifications
  3. Responsive Design

    • Test on mobile devices for touch interactions
    • Ensure adequate touch targets (minimum 44px)
    • Consider content priority for mobile layouts
    • Use appropriate breakpoints for sidebar behavior
  4. Performance

    • Use skeleton states for loading content
    • Implement lazy loading for nested menu items
    • Avoid complex animations on mobile
    • Optimize icon rendering and caching
  5. Accessibility

    • Provide keyboard shortcuts for common actions
    • Test with screen readers thoroughly
    • Ensure sufficient color contrast
    • Use semantic HTML elements appropriately

Advanced Usage

Custom Styling

The sidebar supports extensive customization through CSS custom properties:

:root {
  --sidebar-width: 16rem;
  --sidebar-width-icon: 3rem;
  --sidebar-width-mobile: 18rem;
}

Controlled State

function App() {
  const [sidebarOpen, setSidebarOpen] = useState(true)
  
  return (
    <SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
      {/* sidebar content */}
    </SidebarProvider>
  )
}

Dynamic Content

function DynamicSidebar() {
  const [loading, setLoading] = useState(true)
  const [menuItems, setMenuItems] = useState([])
  
  return (
    <SidebarContent>
      <SidebarMenu>
        {loading ? (
          <>
            <SidebarMenuSkeleton showIcon />
            <SidebarMenuSkeleton showIcon />
            <SidebarMenuSkeleton showIcon />
          </>
        ) : (
          menuItems.map(item => (
            <SidebarMenuItem key={item.id}>
              <SidebarMenuButton>
                <item.icon />
                <span>{item.label}</span>
              </SidebarMenuButton>
            </SidebarMenuItem>
          ))
        )}
      </SidebarMenu>
    </SidebarContent>
  )
}

Common Use Cases

  • Admin Dashboards: Navigation for management interfaces
  • Documentation Sites: Table of contents and page navigation
  • E-commerce Platforms: Category navigation and filters
  • Content Management: File browsers and content organization
  • Analytics Dashboards: Report navigation and tool access
  • Social Platforms: Profile navigation and activity feeds
  • Project Management: Task organization and team navigation
  • Development Tools: Code navigation and tool palettes
  • Sheet
  • Navigation Menu
  • Dropdown Menu
  • Tooltip
  • Separator