Components
Sidebar
An app's rail: a white column with a rule at its edge, quiet rows, mono group labels and the pale blue current row.
npx -y github:anwarlemu/opencommerce-brand-cli#release add sidebar --surface siteRun from the target React 19.2+ app root with Tailwind CSS 4 and the @/* source alias. Source and examples as JSON.
Usage
Use for an app's main navigation (an order console, a developer dashboard). Wrap the page in SidebarProvider with the Sidebar and a SidebarInset beside it; put the wordmark or a switcher in SidebarHeader, groups of SidebarMenu rows in SidebarContent and the account in SidebarFooter. Mark the current row with isActive. Set collapsible="icon" to let it narrow to glyphs (give rows a tooltip), floating or inset for the window looks. On phones it opens in the sheet; Cmd or Ctrl and B toggles it.
- More than seven top-level rows
- Nested rows two levels deep
- A sidebar on a marketing page
- Icons without labels when expanded
Exports: Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, useSidebar. This component includes a client boundary. Keep event handlers and state in a client component.
Dependencies: button, input, separator, sheet, skeleton, tooltip, @radix-ui/react-slot, class-variance-authority, lucide-react.
Example
"use client";
import * as React from "react";
import { BarChart3, BookOpen, ChevronsUpDown, CreditCard, KeyRound, LifeBuoy, PanelLeftClose, Plus, Search, ShoppingBag, Store, Webhook } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
const MAIN = [
{ id: "orders", label: "Orders", icon: ShoppingBag, count: "12" },
{ id: "merchants", label: "Merchants", icon: Store },
{ id: "cards", label: "Cards", icon: CreditCard },
{ id: "monitoring", label: "Monitoring", icon: BarChart3, mark: "Live" },
];
const DEVELOPERS = [
{ id: "keys", label: "API keys", icon: KeyRound },
{ id: "webhooks", label: "Webhooks", icon: Webhook },
{ id: "docs", label: "Docs", icon: BookOpen },
];
function Wordmark() {
return (
<span className="flex items-baseline gap-1.5 px-2 group-data-[collapsible=icon]/sidebar:hidden">
<span className="font-display text-h5 font-medium tracking-tight text-fg">opencommerce</span>
<span className="text-caption text-fg-muted">by Agentcard</span>
</span>
);
}
function CollapseRow() {
const { toggleSidebar, state } = useSidebar();
return (
<SidebarMenuItem>
<SidebarMenuButton tooltip="Expand" onClick={toggleSidebar} aria-expanded={state === "expanded"}>
<PanelLeftClose strokeWidth={1.5} className="transition-[rotate] duration-200 ease-in-out group-data-[collapsible=icon]/sidebar:rotate-180 motion-reduce:transition-none" />
<span>{state === "expanded" ? "Collapse" : "Expand"}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function Rail({ collapsible = "offcanvas", variant = "sidebar" }: { collapsible?: "offcanvas" | "icon" | "none"; variant?: "sidebar" | "floating" | "inset" }) {
const [current, setCurrent] = React.useState("orders");
const [sub, setSub] = React.useState("all");
return (
<Sidebar collapsible={collapsible} variant={variant}>
<SidebarHeader>
<Wordmark />
<SidebarInput placeholder="Search orders" aria-label="Search orders" className="group-data-[collapsible=icon]/sidebar:hidden" />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Commerce</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{MAIN.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton isActive={current === item.id} tooltip={item.label} onClick={() => setCurrent(item.id)}>
<item.icon strokeWidth={1.5} />
<span>{item.label}</span>
</SidebarMenuButton>
{item.count && <SidebarMenuBadge>{item.count}</SidebarMenuBadge>}
{item.mark && <SidebarMenuBadge variant="mark">{item.mark}</SidebarMenuBadge>}
{item.id === "orders" && current === "orders" && (
<SidebarMenuSub>
{[
["all", "All orders"],
["failed", "Failed checkouts"],
["returns", "Returns"],
].map(([id, label]) => (
<SidebarMenuSubItem key={id}>
<SidebarMenuSubButton
href={`#${id}`}
isActive={sub === id}
onClick={(event) => {
event.preventDefault();
setSub(id);
}}
>
<span>{label}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Developers</SidebarGroupLabel>
<SidebarGroupAction aria-label="Create an API key">
<Plus strokeWidth={1.5} />
</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
{DEVELOPERS.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton isActive={current === item.id} tooltip={item.label} onClick={() => setCurrent(item.id)}>
<item.icon strokeWidth={1.5} />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Help">
<LifeBuoy strokeWidth={1.5} />
<span>Help</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="Acme Labs">
<span className="grid size-6 shrink-0 place-items-center rounded-sm border border-line-brand-edge bg-primary font-mono text-bar text-on-primary">A</span>
<span className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-body-sm text-fg">Acme Labs</span>
<span className="truncate font-mono text-bar text-fg-muted">Standard plan</span>
</span>
<ChevronsUpDown strokeWidth={1.5} className="ml-auto" />
</SidebarMenuButton>
</SidebarMenuItem>
{collapsible === "icon" && <CollapseRow />}
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
function Page({ title }: { title: string }) {
return (
<SidebarInset>
<header className="flex h-14 items-center gap-2 border-b border-line px-4">
<SidebarTrigger className="-ml-1.5" />
<p className="font-display text-h5 font-medium tracking-tight text-fg">{title}</p>
<span className="ml-auto flex items-center gap-2 text-caption text-fg-muted max-sm:hidden">
<Search strokeWidth={1.5} className="size-3.5" />
<kbd className="font-mono text-bar">⌘B</kbd> toggles the rail
</span>
</header>
<div className="grid gap-3 p-6">
{["ord_8KQ2 · Northwind Outfitters · $222.00", "ord_8KR9 · Contoso Coffee · $48.50", "ord_8KS6 · Litware Books · $31.20"].map((row) => (
<div key={row} className="rounded-control border border-line px-4 py-3 font-mono text-snippet text-fg">
{row}
</div>
))}
</div>
</SidebarInset>
);
}
export function Site() {
return (
<SidebarProvider className="min-h-150 overflow-hidden rounded-panel border border-line-window">
<Rail />
<Page title="Orders" />
</SidebarProvider>
);
}
export function Icons() {
return (
<SidebarProvider defaultOpen={false} className="min-h-150 overflow-hidden rounded-panel border border-line-window">
<Rail collapsible="icon" />
<Page title="Orders" />
</SidebarProvider>
);
}
export function Floating() {
return (
<SidebarProvider className="min-h-150 overflow-hidden rounded-panel border border-line-window bg-subtle">
<Rail variant="floating" />
<Page title="Merchants" />
</SidebarProvider>
);
}
export function Inset() {
return (
<SidebarProvider className="min-h-150 overflow-hidden rounded-panel border border-line-window">
<Rail variant="inset" />
<Page title="Monitoring" />
</SidebarProvider>
);
}
export function Loading() {
return (
<SidebarProvider className="min-h-100 overflow-hidden rounded-panel border border-line-window">
<Sidebar collapsible="none">
<SidebarHeader>
<Wordmark />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Commerce</SidebarGroupLabel>
<SidebarMenu>
{Array.from({ length: 5 }, (_, i) => (
<SidebarMenuItem key={i}>
<SidebarMenuSkeleton showIcon />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<div className="grid h-full place-items-center p-6 text-body-sm text-fg-muted">Loading your workspace</div>
</SidebarInset>
</SidebarProvider>
);
}Source
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
// An app's rail, extrapolated from the brand's header links and windows (globals.css .nav-link, .win): 256px of white
// with a 9 percent ink rule at its edge; a 64px head holds the wordmark or a switcher. Rows are 34px with 5px corners,
// 8px in from the rail's sides: a 16px line glyph and a 14px Inter label at 64 percent ink that take a 5 percent ink
// wash and ink text under the pointer (150ms, color only); the current row is the pale blue with blue text. A group's
// label is the window bar's 11.5px mono at 64 percent ink. The foot is ruled off above. `floating` is the rail as one of
// the brand's windows (12px corners, the 16 percent ink edge, the soft drop) 8px from the page's edges; `inset` sets the
// rail on the mist with the page as a white window beside it. Collapsing to icons narrows the rail to 51px (200ms, the
// strong ease-out); Cmd or Ctrl and B toggles it instantly; on a phone it opens in the sheet.
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_ICON = "3.1875rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const MOBILE_QUERY = "(max-width: 767px)";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
instant: 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 useIsMobile() {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const query = window.matchMedia(MOBILE_QUERY);
const update = () => setIsMobile(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return isMobile;
}
/**
* Holds whether the rail is open (expanded or collapsed on a desktop, shown in a sheet on a phone). Uncontrolled with
* `defaultOpen`, or controlled with `open` and `onOpenChange`. Nothing is stored: keep the state yourself if it should
* survive a reload. Cmd or Ctrl and B toggles it.
*/
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);
const [instant, setInstant] = React.useState(false);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = openProp ?? internalOpen;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const next = typeof value === "function" ? value(open) : value;
if (setOpenProp) setOpenProp(next);
else setInternalOpen(next);
},
[setOpenProp, open],
);
const toggleSidebar = React.useCallback(() => {
setInstant(false);
return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o);
}, [isMobile, setOpen]);
React.useEffect(() => {
// A shortcut is pressed many times a day: it changes the rail at once, with no motion.
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== SIDEBAR_KEYBOARD_SHORTCUT || !(event.metaKey || event.ctrlKey)) return;
event.preventDefault();
setInstant(true);
if (isMobile) setOpenMobile((o) => !o);
else setOpen((o) => !o);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isMobile, setOpen]);
const state = open ? "expanded" : "collapsed";
const value = React.useMemo<SidebarContextProps>(() => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, instant }), [state, open, setOpen, isMobile, openMobile, toggleSidebar, instant]);
return (
<SidebarContext.Provider value={value}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
data-instant={instant || undefined}
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-subtle", className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
/**
* The rail. `variant`: sidebar (the white rail with its rule), floating (a window 8px from the page's edges) or inset
* (the rail on the mist with the page as a white window beside it). `collapsible`: offcanvas (slides away), icon
* (narrows to its glyphs) or none.
*/
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & { side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none" }) {
const { isMobile, state, openMobile, setOpenMobile, instant } = useSidebar();
const ground = variant === "inset" ? "bg-subtle" : "bg-surface";
if (collapsible === "none") {
return (
<div data-slot="sidebar" data-variant={variant} className={cn("group/sidebar flex h-full w-(--sidebar-width) flex-col text-fg", ground, variant === "sidebar" && "border-r border-line", className)} {...props}>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent data-slot="sidebar" data-sidebar="sidebar" data-mobile="true" data-variant={variant} side={side} name="navigation" className={cn("group/sidebar w-(--sidebar-width) max-w-[calc(100vw-var(--ds-space-12))] text-fg", ground)}>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SheetDescription className="sr-only">The app's sections and settings.</SheetDescription>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
data-slot="sidebar"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
className="group/sidebar peer hidden shrink-0 text-fg md:block"
>
{/* Holds the rail's room in the row, so the page beside it moves as it opens and closes. */}
<div
data-slot="sidebar-gap"
className={cn(
"relative h-full w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-out group-data-[collapsible=offcanvas]/sidebar:w-0 group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon) in-data-[instant=true]:transition-none motion-reduce:transition-none",
(variant === "floating" || variant === "inset") && "group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+var(--ds-space-4))]",
)}
>
<div
data-slot="sidebar-container"
className={cn(
"sticky top-0 z-10 flex h-svh max-h-full w-(--sidebar-width) overflow-hidden transition-[translate,width] duration-200 ease-out in-data-[instant=true]:transition-none motion-reduce:transition-none",
side === "left" ? "left-0 group-data-[collapsible=offcanvas]/sidebar:-translate-x-full" : "ml-auto group-data-[collapsible=offcanvas]/sidebar:translate-x-full",
variant === "floating" || variant === "inset" ? "p-2 group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+var(--ds-space-4))]" : "group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon)",
variant === "sidebar" && (side === "left" ? "border-r border-line" : "border-l border-line"),
className,
)}
{...props}
>
<div data-sidebar="sidebar" data-slot="sidebar-inner" className={cn("flex h-full w-full flex-col", ground, variant === "floating" && "overflow-hidden rounded-panel border border-line-window shadow-card")}>
{children}
</div>
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar, open, isMobile, openMobile } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
aria-expanded={isMobile ? openMobile : open}
className={cn("text-fg-muted", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft strokeWidth={1.5} className="size-4" />
<span className="sr-only">Toggle the rail</span>
</Button>
);
}
/** A thin strip along the rail's edge that toggles it, with a 2px line in the 16 percent ink under the pointer. */
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle the rail"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle the rail"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:bg-transparent after:transition-colors after:duration-150 after:ease-out hover:after:bg-line-strong sm:flex",
"in-data-[side=left]:-right-4 in-data-[side=left]:cursor-w-resize in-data-[side=right]:left-0 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",
className,
)}
{...props}
/>
);
}
/** The page beside the rail. With the inset rail it becomes a white window: 12px corners, the 16 percent ink edge. */
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn("relative flex w-full min-w-0 flex-1 flex-col bg-surface", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-panel md:peer-data-[variant=inset]:border md:peer-data-[variant=inset]:border-line-window md:peer-data-[variant=inset]:shadow-card", className)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return <Input data-slot="sidebar-input" data-sidebar="input" variant="search" className={cn("w-full", className)} {...props} />;
}
/** The rail's 64px head: the wordmark 16px in, or a switcher. */
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sidebar-header" data-sidebar="header" className={cn("flex min-h-16 shrink-0 flex-col justify-center gap-2 px-2 py-3", className)} {...props} />;
}
/** The rail's foot (the account, help, Collapse), ruled off above. */
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sidebar-footer" data-sidebar="footer" className={cn("flex shrink-0 flex-col gap-0.5 border-t border-line 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", className)} {...props} />;
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn("flex min-h-0 flex-1 flex-col overflow-auto overscroll-contain [scrollbar-width:none] group-data-[collapsible=icon]/sidebar:overflow-hidden", className)}
{...props}
/>
);
}
/** A group of rows. A labeled group keeps 8px above and below it; an unlabeled one (the rows under the head) none. */
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 px-2 py-2 [&:not(:has(>[data-slot=sidebar-group-label]))]:py-0", className)} {...props} />;
}
/** A group's label in the window bar's 11.5px mono at 64 percent ink; it folds away when the rail shows glyphs only. */
function SidebarGroupLabel({ className, asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"mb-1 flex h-7 shrink-0 items-center px-2.5 font-mono text-bar leading-none whitespace-nowrap text-fg-muted outline-none transition-[margin,opacity] duration-200 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0 motion-reduce:transition-none [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
)}
{...props}
/>
);
}
/** A group's action beside its label (add a card, filter): a 14px glyph in a 24px key at the group's right. */
function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-2.5 right-3 flex size-6 cursor-pointer items-center justify-center rounded-menu text-fg-muted outline-none transition-[background-color,color] duration-150 ease-out hover:bg-wash-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring group-data-[collapsible=icon]/sidebar:hidden motion-reduce:transition-none [&>svg]:size-3.5 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sidebar-group-content" data-sidebar="group-content" className={cn("w-full", 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.5", 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} />;
}
// A row: a 16px glyph 10px in and the label 8px after it. Hover washes the row; the current row is the pale blue with
// blue text. Narrowed to icons, the row is a 34px square around its glyph.
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-menu pr-2.5 pl-2.5 text-left font-sans text-fg-muted outline-none transition-[background-color,color] duration-150 ease-out group-has-data-[sidebar=menu-action]/menu-item:pr-9 group-data-[collapsible=icon]/sidebar:size-8.5! group-data-[collapsible=icon]/sidebar:justify-center group-data-[collapsible=icon]/sidebar:p-0! group-data-[collapsible=icon]/sidebar:[&>span:not([data-slot=avatar])]:sr-only focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-blue-5 data-[active=true]:text-fg-brand data-[state=open]:bg-wash-hover data-[state=open]:text-fg motion-reduce:transition-none [&>span:last-child]:truncate [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "hover:bg-wash-hover hover:text-fg data-[active=true]:hover:bg-blue-5 data-[active=true]:hover:text-fg-brand",
outline: "border border-hairline bg-surface hover:bg-subtle hover:text-fg",
},
size: {
default: "h-8.5 text-body-sm leading-tight",
sm: "h-7.5 text-caption leading-tight [&>svg:not([class*='size-'])]:size-3.5",
lg: "h-12 text-body-sm leading-tight group-data-[collapsible=icon]/sidebar:p-0!",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps<typeof TooltipContent> } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
// The tooltip names a row only while the rail shows glyphs alone; otherwise it stays shut, so it never catches Escape.
const [tipOpen, setTipOpen] = React.useState(false);
const showTip = state === "collapsed" && !isMobile;
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive || undefined}
aria-current={isActive ? "page" : undefined}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) return button;
const content = typeof tooltip === "string" ? { children: tooltip } : tooltip;
return (
<Tooltip open={showTip && tipOpen} onOpenChange={setTipOpen}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" {...content} />
</Tooltip>
);
}
/** An action at a row's right end (a row's more glyph). `showOnHover` keeps it hidden until the row is hovered or open. */
function SidebarMenuAction({ className, asChild = false, showOnHover = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.25 right-1.5 flex size-6 cursor-pointer items-center justify-center rounded-menu text-fg-muted outline-none transition-[background-color,color,opacity] duration-150 ease-out hover:bg-wash-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring group-data-[collapsible=icon]/sidebar:hidden motion-reduce:transition-none [&>svg]:size-3.5 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-0.75 peer-data-[size=lg]/menu-button:top-3",
showOnHover && "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-fg-brand md:opacity-0",
className,
)}
{...props}
/>
);
}
/**
* A mark at a row's right end, 10px in: `mark` is the brand's chip (the 11.5px mono in blue on the pale blue, 2px
* corners: "Beta", "Live"); `count` is a quiet number in the mono at 64 percent ink (orders waiting).
*/
function SidebarMenuBadge({ className, variant = "count", ...props }: React.ComponentProps<"div"> & { variant?: "mark" | "count" }) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute top-1.75 right-2.5 flex h-5 items-center justify-center whitespace-nowrap select-none group-data-[collapsible=icon]/sidebar:hidden",
"peer-data-[size=sm]/menu-button:top-1.25 peer-data-[size=lg]/menu-button:top-3.5",
variant === "mark" ? "rounded-chip bg-blue-5 px-1.5 font-mono text-bar leading-none text-fg-brand" : "min-w-5 px-1 font-mono text-bar text-fg-muted tabular-nums",
className,
)}
{...props}
/>
);
}
/** A row's placeholder while the rail loads: a glyph square and a label of varying width. */
function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<"div"> & { showIcon?: boolean }) {
// A width between 50 and 90 percent that differs from row to row but is the same on the server and in the browser.
const id = React.useId();
const width = `${50 + (([...id].reduce((sum, c) => (sum * 31 + c.charCodeAt(0)) % 997, 7) % 41))}%`;
return (
<div data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" className={cn("flex h-8.5 items-center gap-2 rounded-menu px-2.5", className)} {...props}>
{showIcon && <Skeleton className="size-4 rounded-chip" data-sidebar="menu-skeleton-icon" />}
<Skeleton className="h-3 max-w-(--skeleton-width) flex-1 rounded-chip" data-sidebar="menu-skeleton-text" style={{ "--skeleton-width": width } as React.CSSProperties} />
</div>
);
}
/** Rows nested under a row, hung from a 9 percent ink rule under the parent's glyph. */
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn("mx-4.5 mt-0.5 flex min-w-0 flex-col gap-0.5 border-l border-line py-0.5 pl-2 group-data-[collapsible=icon]/sidebar: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({ asChild = false, size = "md", isActive = false, className, ...props }: React.ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive || undefined}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex h-7.5 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-menu px-2.5 font-sans text-fg-muted outline-none transition-[background-color,color] duration-150 ease-out hover:bg-wash-hover hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-blue-5 data-[active=true]:text-fg-brand motion-reduce:transition-none [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
size === "sm" ? "text-caption" : "text-body-sm",
className,
)}
{...props}
/>
);
}
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,
};Reference: Extension from opencommerce-agentcard-web src/app/globals.css (.nav-link, .win).