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.
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.
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.
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
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;
}
}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.
Sidebar
Renders a ResizablePanel (as a <div>) on desktop and a Sheet on mobile.
SidebarMenuButton
Renders a <button> (default) or the provided asChild.