implement
Sidebar

Sidebar

A collapsible application sidebar, with an off-canvas sheet on mobile.

Inbox
Collapse the sidebar with the button, or the rail on its edge.
import { Div, signal, Span } from "@implementjs/core";
import { CalendarIcon, InboxIcon, SearchIcon, SettingsIcon, UsersIcon } from "@implementjs/lucide";
import { Separator } from "@/lib/components/ui/separator";
import {
	Sidebar,
	SidebarContent,
	SidebarFooter,
	SidebarGroup,
	SidebarGroupContent,
	SidebarGroupLabel,
	SidebarHeader,
	SidebarInset,
	SidebarMenu,
	SidebarMenuBadge,
	SidebarMenuButton,
	SidebarMenuItem,
	SidebarProvider,
	SidebarRail,
	SidebarTrigger,
} from "@/lib/components/ui/sidebar";

const items = [
	{ title: "Inbox", icon: InboxIcon, badge: "12" },
	{ title: "Search", icon: SearchIcon, badge: null },
	{ title: "Calendar", icon: CalendarIcon, badge: null },
	{ title: "Team", icon: UsersIcon, badge: "3" },
];

export default function SidebarDemo() {
	const active = signal("Inbox");

	return Div(
		// A real app mounts the shell at the page root, where the sidebar's
		// `fixed` positioning is what you want. To hold it inside a demo box
		// instead, `transform-gpu` makes this element the containing block for
		// fixed descendants — the shell then treats the box as its viewport.
		{ class: "relative h-96 w-full transform-gpu overflow-hidden rounded-lg border" },
		SidebarProvider(
			{ class: "min-h-full! [--sidebar-width:13rem]", keyboardShortcut: false },
			Sidebar(
				{ collapsible: "icon", class: "h-full!" },
				SidebarHeader(
					Div(
						{ class: "px-2 py-1 text-sm font-semibold group-data-[collapsible=icon]:hidden" },
						"Acme Inc.",
					),
				),
				SidebarContent(
					SidebarGroup(
						SidebarGroupLabel("Workspace"),
						SidebarGroupContent(
							SidebarMenu(
								...items.map((item) =>
									SidebarMenuItem(
										SidebarMenuButton(
											{
												tooltip: item.title,
												isActive: active.get() === item.title,
												onClick: () => active.set(item.title),
											},
											item.icon({ "aria-hidden": true }),
											Span(item.title),
										),
										item.badge ? SidebarMenuBadge(item.badge) : null,
									),
								),
							),
						),
					),
				),
				SidebarFooter(
					SidebarMenu(
						SidebarMenuItem(
							SidebarMenuButton(
								{ tooltip: "Settings" },
								SettingsIcon({ "aria-hidden": true }),
								Span("Settings"),
							),
						),
					),
				),
				SidebarRail(),
			),
			SidebarInset(
				Div(
					{ class: "flex h-12 shrink-0 items-center gap-2 border-b px-3" },
					SidebarTrigger(),
					Separator({ orientation: "vertical", class: "h-4" }),
					Span({ class: "text-sm font-medium" }, active),
				),
				Div(
					{ class: "flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground" },
					"Collapse the sidebar with the button, or the rail on its edge.",
				),
			),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/sidebar

jsrepo pulls button, input, separator, sheet, skeleton and tooltip along with it, and installs @implementjs/lucide and tailwind-variants.

Usage

The largest component here, and the only one with real state. SidebarProvider owns whether the sidebar is open and hands it to every part through context, so the trigger, the rail, and the inset stay in step without being wired to each other.

import {
	Sidebar,
	SidebarContent,
	SidebarInset,
	SidebarProvider,
	SidebarTrigger,
} from "@/lib/components/ui/sidebar";

SidebarProvider(
	Sidebar({ collapsible: "icon" }, SidebarContent(/* groups and menus */)),
	SidebarInset(Header(SidebarTrigger()), Main(/* the page */)),
);

⌘B (Ctrl+B) toggles it. Pass keyboardShortcut: false to leave the chord alone.

The CSS it needs

On top of the tokens in the introduction, the sidebar has its own palette so it can sit a shade off the page without dragging every surface token with it:

:root {
	--sidebar: #0a0a0a;
	--sidebar-foreground: #fff;
	--sidebar-primary: #fff;
	--sidebar-primary-foreground: #000;
	--sidebar-accent: #222;
	--sidebar-accent-foreground: #fff;
	--sidebar-border: #222;
	--sidebar-ring: #fff;
}

@theme inline {
	--color-sidebar: var(--sidebar);
	--color-sidebar-foreground: var(--sidebar-foreground);
	--color-sidebar-primary: var(--sidebar-primary);
	--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
	--color-sidebar-accent: var(--sidebar-accent);
	--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
	--color-sidebar-border: var(--sidebar-border);
	--color-sidebar-ring: var(--sidebar-ring);
}

The widths are variables too — --sidebar-width, --sidebar-width-icon, --sidebar-width-mobile — but every use of them carries a fallback, so the sidebar has a size before you have set any. Override them in :root, or per-layout from a class:

SidebarProvider({ class: "[--sidebar-width:20rem]" } /* ... */);

Collapsing

collapsible decides what "closed" means:

  • offcanvas — slides fully out of view. The default.
  • icon — shrinks to a rail of icons. Labels, badges, actions, and submenus hide themselves.
  • none — never collapses, and skips the state machinery entirely.

In icon mode a row is just a glyph, so give it a tooltip. It only appears while collapsed — an expanded sidebar already shows the label, and repeating it would be noise:

SidebarMenuButton({ tooltip: "Inbox" }, InboxIcon({ "aria-hidden": true }), Span("Inbox"));

Rows that navigate

SidebarMenuButton is a button; SidebarMenuLink is an anchor with identical styling. They are separate components because the tooltip primitive's trigger is itself a button, so one component cannot be both — the same split as CommandItem and CommandLinkItem.

tooltip therefore belongs to the button form. A link row in an icon rail wants its label some other way — an aria-label, or title.

SidebarMenuItem(
	SidebarMenuLink(
		{ href: "/inbox", isActive: true },
		InboxIcon({ "aria-hidden": true }),
		Span("Inbox"),
	),
);

Mobile

Below 768px the sidebar becomes a sheet — an off-canvas panel with the dialog's focus trap and dismissal — and SidebarTrigger opens that instead. The switch is a matchMedia listener in the provider, so it follows a resize, and a sheet left open on a phone closes itself when the layout goes back to a docked sidebar.

Server rendering has no viewport to measure, so the desktop tree is the one that prerenders and the client corrects on mount.

Persisting the open state

There is no cookie and no localStorage here, on purpose: a static build cannot read either before it paints, so built-in persistence would only buy a flash of the wrong state. Instead open takes a signal, which makes persistence yours to place:

const open = signal(localStorage.getItem("sidebar") !== "closed");
open.subscribe((value) => localStorage.setItem("sidebar", value ? "open" : "closed"));

SidebarProvider({ open } /* ... */);

Variants

sidebar sits flush against the edge. floating lifts it into a rounded card with its own border. inset keeps the sidebar flush and floats the page beside it — SidebarInset picks that up on its own.

API Reference

SidebarProvider

Owns the open state and provides it to every part, so the trigger, the rail, and the inset stay in step without being wired to each other. It also watches the viewport and binds the keyboard shortcut. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
openSignal<boolean>Own the desktop open state from outside — which is also how you persist it. Omit for uncontrolled.
defaultOpenbooleantrueStarting state when uncontrolled.
keyboardShortcutbooleantrueWhether ⌘B / Ctrl+B toggles the sidebar.

The sidebar itself. Below 768px it renders as a Sheet instead — an off-canvas panel with the dialog's focus trap and dismissal — and the desktop tree is the one that prerenders. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
side"left" | "right""left"Which edge it docks to.
variant"sidebar" | "floating" | "inset""sidebar"Flush against the edge, floated as a rounded card, or inset with the page floating beside it.
collapsible"offcanvas" | "icon" | "none""offcanvas"Slide fully out of view, shrink to a rail of icons, or never collapse at all. none skips the state machinery entirely.
Data attributeValue
[data-state]"expanded" | "collapsed"
[data-collapsible]The collapsible mode while collapsed, else ""
[data-variant]"sidebar" | "floating" | "inset"
[data-side]"left" | "right"
CSS variableDescription
--sidebar-widthExpanded width. Defaults to 16rem.
--sidebar-width-iconWidth of the icon rail. Defaults to 3rem.
--sidebar-width-mobileWidth of the off-canvas sheet. Defaults to 18rem.

SidebarTrigger

Toggles whichever open state applies — the desktop one or the mobile sheet. Renders a panel icon unless you pass children. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""ghost"Which button style to render.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""icon-sm"Height and padding.

SidebarRail

The strip along the sidebar's inner edge: click it to toggle. Out of the tab order, since the trigger already does the same job. Renders a Button; extra props are forwarded onto it.

SidebarInset

The page beside the sidebar. Under the inset variant it floats as a rounded card with a margin. Renders a Div; extra props are forwarded onto it.

SidebarHeader

The block at the top, above the scrolling content. Renders a Div; extra props are forwarded onto it.

SidebarContent

The scrolling middle. Collapsed to icons it stops scrolling. Renders a Div; extra props are forwarded onto it.

SidebarFooter

The block at the bottom. Renders a Div; extra props are forwarded onto it.

SidebarSeparator

A line across the sidebar, inset from its edges. Renders a Separator; extra props are forwarded onto it.

SidebarInput

An input sized for the sidebar — shorter, and without a shadow. Renders a Input; extra props are forwarded onto it.

SidebarGroup

A titled section of the sidebar. Renders a Div; extra props are forwarded onto it.

SidebarGroupLabel

The section's title. It slides up and fades out as the sidebar collapses to icons. Renders a Div; extra props are forwarded onto it.

SidebarGroupAction

A control in the section's top right. Hidden while collapsed to icons. Renders a Button; extra props are forwarded onto it.

SidebarGroupContent

The section's body. Renders a Div; extra props are forwarded onto it.

SidebarMenu

A list of navigation rows. Renders a Ul; extra props are forwarded onto it.

SidebarMenuItem

One row, and anything anchored to it. Renders a Li; extra props are forwarded onto it.

SidebarMenuButton

A row that acts. `tooltip` is what makes an icon-collapsed sidebar usable: the label reappears on hover, and only while collapsed. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
tooltipstringLabel to show while the sidebar is collapsed to icons. Setting it makes the row the tooltip's trigger.
isActivebooleanfalseMarks the current row. Written out as data-active, which the styles read.
variant"default" | "outline""default"Plain, or on a background with a ring.
size"default" | "sm" | "lg""default"Row height. lg is the shape for an account row with an avatar.

A row that navigates, styled identically. It exists separately because the tooltip primitive's trigger is a button, so one component cannot be both — the same split as CommandItem and CommandLinkItem. Renders a A; extra props are forwarded onto it.

PropTypeDefaultDescription
isActivebooleanfalseMarks the current row.
variant"default" | "outline""default"Plain, or on a background with a ring.
size"default" | "sm" | "lg""default"Row height.

SidebarMenuAction

A control anchored to a row's right edge. Hidden while collapsed to icons. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
showOnHoverbooleanfalseReveal it only on hover or keyboard focus. It stays visible on touch, where there is no hover to reveal it.

SidebarMenuBadge

A count on a row's right edge. Not interactive, and hidden while collapsed to icons. Renders a Div; extra props are forwarded onto it.

SidebarMenuSkeleton

A placeholder row, for a menu whose items are still loading. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
showIconbooleanfalseAlso draw a square where the row's icon will be.
widthstring"70%"Text width for the row. Vary it across a list so the placeholders do not look ruled.

SidebarMenuSub

A nested list under a row. Hidden while collapsed to icons. Renders a Ul; extra props are forwarded onto it.

SidebarMenuSubItem

One nested row. Renders a Li; extra props are forwarded onto it.

SidebarMenuSubButton

A nested row that acts. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
size"sm" | "md""md"Text size for the nested row.
isActivebooleanfalseMarks the current nested row.

A nested row that navigates. Renders a A; extra props are forwarded onto it.

PropTypeDefaultDescription
size"sm" | "md""md"Text size for the nested row.
isActivebooleanfalseMarks the current nested row.

useSidebar

Render with the nearest sidebar's state: useSidebar(({ open, openMobile, isMobile, state, toggle }) => …). The parts' own escape hatch, for anything the parts do not cover.