Sidebar

A composable, mobile-responsive sidebar component.

Installation

pnpm dlx shadcn@latest add @lumi-ui/sidebar

Inspired by shadcn/ui and built using react-resizable-panels.

Live examples

Anatomy

<SidebarProvider>
  <Sidebar>
    <SidebarHeader />
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel />
        <SidebarGroupContent>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton />
              <SidebarMenuSub>
                <SidebarMenuSubItem />
              </SidebarMenuSub>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroupContent>
      </SidebarGroup>
      <SidebarFooter />
    </Sidebar>
    <MainContent />
</SidebarProvider>

Configuration

The sidebar behavior is controlled by a few constants in the sidebar.tsx file. You can modify these to fit your needs.

ConstantDefaultDescription
SIDEBAR_COOKIE_MAX_AGE7 daysHow long the sidebar state (cookie) persists.
DEFAULT_WIDTH25remThe default width of the sidebar when expanded.
MIN_WIDTH15remThe minimum width the sidebar can be resized to.
MAX_WIDTH25remThe maximum width the sidebar can be resized to (often same as default for fixed-width).
SIDEBAR_KEYBOARD_SHORTCUTbThe keyboard shortcut (Command+b or Control+b) to toggle the sidebar.

Usage

CollapsibleType

You can set the collapsibleType prop to either icon or sidebar to control how the sidebar collapses. ICON_MODE_WIDTH_PX is the width of the sidebar in icon mode. If set sidebar, the sidebar will be hidden when collapsed. See examples for more details.

With Nextjs

To prevent hydration mismatch and maintain the sidebar state between refreshes, it's recommended to read the state from cookies server-side and pass it to the SidebarProvider.

layout.tsx
import { AppSidebar } from "@/components/app-sidebar"
import {
  MainContent,
  SidebarProvider,
} from "@components/ui/sidebar"
import { cookies } from "next/headers"
import type { Layout } from "react-resizable-panels"
 
const GROUP_ID = "main-layout-persistence";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const layoutCookie = cookieStore.get(GROUP_ID);
 
  const defaultLayout: Layout = layoutCookie
    ? JSON.parse(layoutCookie.value)
    : undefined;
 
  return (
    <SidebarProvider 
      collapsibleType="icon"
      defaultLayout={defaultLayout}
      groupId={GROUP_ID}
    >
      <AppSidebar />
      <MainContent>{children}</MainContent>
    </SidebarProvider>
  )
}

With TanStack Start

utils.ts
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
import type { Layout } from "react-resizable-panels";
 
const GROUP_ID = "main-layout-persistence";
 
export const getSidebarLayout = createServerFn({ method: "GET" }).handler(
	async () => {
		const raw = getCookie(GROUP_ID);
		if (!raw) return undefined;
 
		try {
			return JSON.parse(raw) as Layout;
		} catch {
			return undefined;
		}
	},
);
 
function getCookieValue(name: string): string | undefined {
	if (typeof document === "undefined") return undefined;
 
	const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
	const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
	return m ? m[1] : undefined;
}
 
export function getSidebarLayoutClient(): Layout | undefined {
	const raw = getCookieValue(GROUP_ID);
	if (!raw) return undefined;
 
	try {
		return JSON.parse(decodeURIComponent(raw)) as Layout;
	} catch {
		return undefined;
	}
}
route.tsx
import { AppSidebar } from "@/components/app-sidebar";
import { MainContent, SidebarProvider } from "@/components/ui/sidebar";
import { getSidebarLayout, getSidebarLayoutClient } from "@/utils";
import { createFileRoute, Outlet } from "@tanstack/react-router";
 
const GROUP_ID = "main-layout-persistence";
 
export const Route = createFileRoute("/dashboard/")({
	component: RouteComponent,
	beforeLoad: async () => {
		if (typeof document === "undefined") {
			return { defaultLayout: await getSidebarLayout() };
		} else {
			return { defaultLayout: getSidebarLayoutClient() };
		}
	},
});
 
function RouteComponent() {
	const { defaultLayout } = Route.useRouteContext();
	return (
		<SidebarProvider
			collapsibleType="icon"
			groupId={GROUP_ID}
			defaultLayout={defaultLayout}
		>
			<AppSidebar />
			<MainContent>
        <Outlet />
      </MainContent>
		</SidebarProvider>
	);
}
 

With Client-Side Cookies

Replace SidebarProvider with code below:

const SidebarProvider = ({
	collapsibleType = "icon",
	children,
	groupId,
}: {
	children: ReactNode;
	collapsibleType?: Collapsible;
	groupId: string;
}) => {
	const { defaultLayout, onLayoutChanged } = useDefaultLayout({
		id: groupId,
		storage: localStorage,
	});
	const isMobile = useIsMobile();
	const sidebarRef = usePanelRef();
	const mobileHandle = useMemo(() => createSheetHandle(), []);
	const collapsedSize = collapsibleType === "icon" ? ICON_MODE_WIDTH_PX : 0;
 
	// For icon
	const [isCollapsed, setIsCollapsed] = useState(() => {
		// If no cookie, default to expanded
		if (!defaultLayout || !defaultLayout.sidebar) return false;
 
		// The cookie value is a percentage (0-100)
		const savedPercentage = defaultLayout.sidebar;
 
		// Compare against our safety threshold
		return savedPercentage < COLLAPSED_PERCENTAGE_THRESHOLD;
	});
 
	const toggleSidebar = React.useCallback(() => {
		const panel = sidebarRef.current;
		if (!panel) return;
		if (panel.isCollapsed()) {
			panel.expand();
		} else {
			panel.collapse();
		}
	}, [sidebarRef]);
 
	React.useEffect(() => {
		const handleKeyDown = (event: KeyboardEvent) => {
			if (
				event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
				(event.metaKey || event.ctrlKey)
			) {
				event.preventDefault();
				toggleSidebar();
			}
		};
		window.addEventListener("keydown", handleKeyDown);
		return () => window.removeEventListener("keydown", handleKeyDown);
	}, [toggleSidebar]);
 
	return (
		<SidebarContext.Provider
			value={{
				collapsedSize,
				collapsibleType,
				isCollapsed,
				isMobile,
				mobileHandle,
				setIsCollapsed,
				sidebarRef,
				toggleSidebar,
			}}
		>
			<TooltipProvider>
				<div className="h-screen w-screen overflow-hidden">
					<ResizableGroup
						data-slot="sidebar-wrapper"
						id={groupId}
						defaultLayout={defaultLayout}
						onLayoutChanged={onLayoutChanged}
						orientation="horizontal"
						resizeTargetMinimumSize={{ coarse: 37, fine: 27 }}
					>
						{children}
					</ResizableGroup>
				</div>
			</TooltipProvider>
		</SidebarContext.Provider>
	);
};

API Reference

SidebarProvider

Renders a <div> that wraps the application layout and handles state management.

PropTypeDescription
defaultLayoutLayoutInitial layout state, usually restored from cookies.
collapsibleType"icon" | "sidebar"Defines how the sidebar collapses (icon mode or full hide). Defaults to "icon".
groupIdstringUnique ID for the sidebar group to scope the persistence cookie.
childrenReactNodeThe content to wrap.

Renders a ResizablePanel (as a <div>) on desktop and a Sheet on mobile.

PropTypeDescription
side"left" | "right"The side of the screen the sidebar appears on. Defaults to "left".
variant"sidebar" | "floating" | "inset"The visual variant of the sidebar.
collapsible"offcanvas" | "icon" | "none"Controls the collapsed behavior.

SidebarMenuButton

Renders a <button> (default) or the provided asChild.

PropTypeDescription
isActivebooleanWhether the button is actively selected.
variant"default" | "outline"The visual variant of the button.
size"default" | "sm" | "lg"The size of the button.
tooltipstring | ComponentProps<typeof TooltipContent>Tooltip content to show when the sidebar is collapsed.

Other Components

ComponentRendersDescription
SidebarContentdivThe scrollable content area within the sidebar.
SidebarHeaderdivA fixed header area at the top of the sidebar.
SidebarFooterdivA fixed footer area at the bottom of the sidebar.
SidebarGroupdivA section or group of items within the sidebar.
SidebarGroupLabeldivA label for a sidebar group.
SidebarGroupContentdivThe content/items within a sidebar group.
SidebarMenuulA container for menu items.
SidebarMenuItemliAn individual item in the menu.
SidebarMenuSubulA container for nested sub-menu items.
SidebarMenuSubItemliAn individual item in a sub-menu.
SidebarMenuSubButtonaThe clickable button within a sub-menu item.
SidebarTriggerButtonA button to toggle the sidebar's visibility.
SidebarSeparatorSeparatorA visual separator between items.
MainContentdivThe area for your page's main content, adjacent to the sidebar.