Skip to content
Dethink Components

ComponentsSidebar

Sidebar

Organize app navigation in a sidebar that can collapse.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files
  • Motion for React (motion)

    Animations, transitions, and reduced-motion handling.

  • React DOM (react-dom)

    Portals for rendering overlay content outside the normal component tree.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import {
  Sidebar,
  SidebarProvider,
  SidebarMenuLink,
} from "@dethink/components";

Try the examples, then open the code to use them in your app.

Background work

Keep AI runs, imports, and approval requests visible. Collapse the sidebar to turn the list into an activity summary.

Show sourceexamples/sidebar/activity.tsx
examples/sidebar/activity.tsx
"use client";

import { useState, useSyncExternalStore } from "react";
import {
  Button,
  Sidebar,
  SidebarActivity,
  SidebarContent,
  SidebarHeader,
  SidebarProvider,
  SidebarTrigger,
  type SidebarActivityItem,
} from "@dethink/components";
import { Layers } from "lucide-react";

const subscribeHydration = () => () => {};

export function SidebarActivityExample() {
  const hydrated = useSyncExternalStore(
    subscribeHydration,
    () => true,
    () => false,
  );
  const [reviewed, setReviewed] = useState(false);
  const [finished, setFinished] = useState(false);
  const [empty, setEmpty] = useState(false);
  const items: SidebarActivityItem[] = empty
    ? []
    : [
        {
          id: "brief",
          title: "Launch brief",
          status: reviewed ? "complete" : "attention",
          description: reviewed
            ? "Approved and ready to share"
            : "Your approval is needed to continue",
          action: reviewed
            ? undefined
            : { label: "Approve brief", onAction: () => setReviewed(true) },
        },
        {
          id: "index",
          title: "Indexing documents",
          status: finished ? "complete" : "running",
          progress: 62,
          description: finished
            ? "128 documents ready to search"
            : "79 of 128 documents",
          action: finished
            ? undefined
            : { label: "Finish demo run", onAction: () => setFinished(true) },
        },
        {
          id: "export",
          title: "Weekly report",
          status: "queued",
          description: "Waiting for the index",
        },
      ];
  return (
    <fieldset
      disabled={!hydrated}
      aria-label="Activity demo"
      className="min-w-0 space-y-4"
      data-testid="sidebar-activity-demo"
    >
      <div className="flex flex-wrap gap-2">
        <Button size="sm" variant="outline" onClick={() => setEmpty(!empty)}>
          {empty ? "Show activity" : "Clear activity"}
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => {
            setReviewed(false);
            setFinished(false);
            setEmpty(false);
          }}
        >
          Reset demo
        </Button>
      </div>
      <SidebarProvider motion="subtle" className="mx-auto max-w-64">
        <Sidebar
          className="h-[31rem] rounded-xl border"
          aria-label="Activity workspace"
        >
          <SidebarHeader>
            <div className="flex items-center gap-2 group-data-[collapsed=true]/sidebar:flex-col">
              <span className="flex size-9.5 shrink-0 items-center justify-center">
                <Layers
                  aria-hidden="true"
                  data-testid="activity-workspace-icon"
                  className="text-primary size-5 shrink-0"
                />
              </span>
              <span className="min-w-0 flex-1 truncate text-sm font-semibold group-data-[collapsed=true]/sidebar:hidden">
                Studio workspace
              </span>
              <SidebarTrigger className="size-9.5" />
            </div>
          </SidebarHeader>
          <SidebarContent>
            <SidebarActivity items={items} label="Background work" />
          </SidebarContent>
        </Sidebar>
      </SidebarProvider>
    </fieldset>
  );
}

Dashboard navigation

A labelled nav landmark with grouped links, current route state, badges, shortcuts, a visible header toggle, and content inset.

Main content keeps its own layout. Sidebar only owns navigation.
Show sourceexamples/sidebar/basic.tsx
examples/sidebar/basic.tsx
"use client";

import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuLink,
  SidebarProvider,
  SidebarTrigger,
} from "@dethink/components";
import { BarChart3, LayoutDashboard } from "lucide-react";

export function SidebarBasic() {
  return (
    <SidebarProvider motion="subtle">
      <div className="border-border bg-background flex h-80 overflow-hidden rounded-lg border">
        <Sidebar aria-label="Product navigation">
          <SidebarHeader>
            <div className="flex min-w-0 items-center justify-between gap-2">
              <div className="min-w-0 group-data-[collapsed=true]/sidebar:hidden">
                <div className="text-foreground truncate text-sm font-semibold">
                  Dethink Cloud
                </div>
                <div className="text-muted-foreground truncate text-xs">
                  Operations
                </div>
              </div>
              <SidebarTrigger />
            </div>
          </SidebarHeader>
          <SidebarContent>
            <SidebarGroup>
              <SidebarGroupLabel>Workspace</SidebarGroupLabel>
              <SidebarGroupContent>
                <SidebarMenu>
                  <SidebarMenuItem>
                    <SidebarMenuLink
                      current
                      href="/overview"
                      icon={<LayoutDashboard aria-hidden="true" />}
                    >
                      Overview
                    </SidebarMenuLink>
                  </SidebarMenuItem>
                  <SidebarMenuItem>
                    <SidebarMenuLink
                      badge="12"
                      href="/analytics"
                      icon={<BarChart3 aria-hidden="true" />}
                      shortcut="G A"
                    >
                      Analytics
                    </SidebarMenuLink>
                  </SidebarMenuItem>
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          </SidebarContent>
        </Sidebar>
        <SidebarInset className="p-6">
          <div className="border-border bg-muted/30 text-muted-foreground rounded-md border p-4 text-sm">
            Main content keeps its own layout. Sidebar only owns navigation.
          </div>
        </SidebarInset>
      </div>
    </SidebarProvider>
  );
}

Collapsed rail

Icon rail mode preserves accessible names while trading visual labels for workspace width.

Collapsed labels remain in the accessibility tree while visual space stays compact.
Show sourceexamples/sidebar/collapsed.tsx
examples/sidebar/collapsed.tsx
"use client";

import {
  Sidebar,
  SidebarContent,
  SidebarHeader,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarInset,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuLink,
  SidebarProvider,
  SidebarTrigger,
} from "@dethink/components";
import { FileBarChart, FolderKanban, Inbox } from "lucide-react";

const collapsedItems = [
  { href: "/inbox", icon: Inbox, label: "Inbox" },
  { href: "/projects", icon: FolderKanban, label: "Projects" },
  { href: "/reports", icon: FileBarChart, label: "Reports" },
];

export function SidebarCollapsed() {
  return (
    <SidebarProvider defaultCollapsed motion="subtle">
      <div className="border-border bg-background flex h-72 overflow-hidden rounded-lg border">
        <Sidebar aria-label="Collapsed navigation" variant="bordered">
          <SidebarHeader>
            <SidebarTrigger />
          </SidebarHeader>
          <SidebarContent>
            <SidebarGroup>
              <SidebarGroupLabel>Rail</SidebarGroupLabel>
              <SidebarGroupContent>
                <SidebarMenu>
                  {collapsedItems.map((item, index) => {
                    const Icon = item.icon;

                    return (
                      <SidebarMenuItem key={item.href}>
                        <SidebarMenuLink
                          current={index === 0}
                          href={item.href}
                          icon={<Icon aria-hidden="true" />}
                        >
                          {item.label}
                        </SidebarMenuLink>
                      </SidebarMenuItem>
                    );
                  })}
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          </SidebarContent>
        </Sidebar>
        <SidebarInset className="text-muted-foreground p-5 text-sm">
          Collapsed labels remain in the accessibility tree while visual space
          stays compact.
        </SidebarInset>
      </div>
    </SidebarProvider>
  );
}

Mobile menu

A contained mobile navigation handoff with explicit open and close states for embedded workspace previews.

Mobile workspace
Open the menu and choose a workspace link.
Show sourceexamples/sidebar/mobile-drawer.tsx
examples/sidebar/mobile-drawer.tsx
"use client";

import {
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuLink,
  SidebarMobileTrigger,
  SidebarProvider,
} from "@dethink/components";
import { Activity, Home, Menu, Settings, X } from "lucide-react";
import { useState } from "react";

const mobileLinks = [
  { href: "#overview", icon: Home, label: "Overview" },
  { href: "#incidents", icon: Activity, label: "Incidents" },
  { href: "#settings", icon: Settings, label: "Settings" },
];

export function SidebarMobileDrawer() {
  const [open, setOpen] = useState(false);

  return (
    <SidebarProvider mobileOpen={open} onMobileOpenChange={setOpen}>
      <div className="border-border bg-background overflow-hidden rounded-lg border">
        <div className="flex items-center justify-between gap-4 p-6">
          <div className="flex min-w-0 items-center gap-3">
            <span
              aria-hidden="true"
              className="border-border bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-md border"
            >
              <Home className="size-4" />
            </span>
            <div className="min-w-0">
              <div className="text-foreground text-sm font-semibold">
                Mobile workspace
              </div>
              <div className="text-muted-foreground text-xs">
                Open the menu and choose a workspace link.
              </div>
            </div>
          </div>
          <SidebarMobileTrigger
            aria-controls="sidebar-mobile-menu-preview"
            className="size-9"
            closeLabel="Close menu"
            openLabel="Open menu"
          >
            {open ? (
              <X aria-hidden="true" className="size-4" />
            ) : (
              <Menu aria-hidden="true" className="size-4" />
            )}
          </SidebarMobileTrigger>
        </div>

        <div
          hidden={!open}
          id="sidebar-mobile-menu-preview"
          data-state={open ? "open" : "closed"}
          className="border-border bg-muted/30 data-[state=open]:motion-safe:animate-sidebar-menu-open border-t p-3 data-[state=open]:motion-reduce:animate-none"
        >
          <SidebarContent>
            <SidebarGroup>
              <SidebarGroupLabel>Workspace</SidebarGroupLabel>
              <SidebarGroupContent>
                <SidebarMenu>
                  {mobileLinks.map((item, index) => {
                    const Icon = item.icon;

                    return (
                      <SidebarMenuItem key={item.href}>
                        <SidebarMenuLink
                          current={index === 0}
                          href={item.href}
                          icon={<Icon aria-hidden="true" />}
                          onClick={() => setOpen(false)}
                        >
                          {item.label}
                        </SidebarMenuLink>
                      </SidebarMenuItem>
                    );
                  })}
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          </SidebarContent>
        </div>
      </div>
    </SidebarProvider>
  );
}

Examples that combine components for common tasks.

AI workspace navigation

A floating sidebar with nested workflow navigation, live badges, disabled items, motion preset state, and dashboard content.

38 active runs

Navigation state stays visible while the workspace updates.

92% eval pass rate

Badges and descriptions fit dense AI-native surfaces.

Show sourceexamples/sidebar/recipe-ai-workspace.tsx
examples/sidebar/recipe-ai-workspace.tsx
"use client";

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupTrigger,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuAction,
  SidebarMenuItem,
  SidebarMenuLink,
  SidebarProvider,
  SidebarTrigger,
} from "@dethink/components";
import { Activity, Gauge, Settings2, Sparkles } from "lucide-react";

export function SidebarRecipeAiWorkspace() {
  return (
    <SidebarProvider variant="floating" motion="subtle">
      <div className="border-border bg-muted/30 flex h-[28rem] overflow-hidden rounded-lg border">
        <Sidebar aria-label="AI workspace navigation">
          <SidebarHeader>
            <div className="flex min-w-0 items-center justify-between gap-2">
              <div className="min-w-0 group-data-[collapsed=true]/sidebar:hidden">
                <div className="text-foreground truncate text-sm font-semibold">
                  Assist Studio
                </div>
                <div className="text-muted-foreground truncate text-xs">
                  Model operations
                </div>
              </div>
              <SidebarTrigger />
            </div>
          </SidebarHeader>
          <SidebarContent>
            <SidebarGroup collapsible defaultOpen>
              <SidebarGroupTrigger>Workflows</SidebarGroupTrigger>
              <SidebarGroupContent>
                <SidebarMenu>
                  <SidebarMenuItem>
                    <SidebarMenuLink
                      current
                      badge="Live"
                      description="Streaming review queue"
                      href="#runs"
                      icon={<Sparkles aria-hidden="true" />}
                    >
                      Agent runs
                    </SidebarMenuLink>
                  </SidebarMenuItem>
                  <SidebarMenuItem>
                    <SidebarMenuLink
                      href="#evaluations"
                      icon={<Activity aria-hidden="true" />}
                    >
                      Evaluations
                    </SidebarMenuLink>
                  </SidebarMenuItem>
                  <SidebarMenuItem>
                    <SidebarMenuLink
                      disabled
                      href="#billing"
                      icon={<Gauge aria-hidden="true" />}
                    >
                      Cost controls
                    </SidebarMenuLink>
                  </SidebarMenuItem>
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          </SidebarContent>
          <SidebarFooter>
            <div className="flex items-center justify-between gap-2">
              <span className="text-muted-foreground truncate text-sm">
                Team quota
              </span>
              <SidebarMenuAction aria-label="Open quota settings">
                <Settings2 aria-hidden="true" />
              </SidebarMenuAction>
            </div>
          </SidebarFooter>
        </Sidebar>
        <SidebarInset className="p-6">
          <div className="grid gap-4 md:grid-cols-2">
            <div className="border-border bg-background rounded-md border p-4">
              <div className="text-foreground text-sm font-semibold">
                38 active runs
              </div>
              <p className="text-muted-foreground mt-2 text-sm">
                Navigation state stays visible while the workspace updates.
              </p>
            </div>
            <div className="border-border bg-background rounded-md border p-4">
              <div className="text-foreground text-sm font-semibold">
                92% eval pass rate
              </div>
              <p className="text-muted-foreground mt-2 text-sm">
                Badges and descriptions fit dense AI-native surfaces.
              </p>
            </div>
          </div>
        </SidebarInset>
      </div>
    </SidebarProvider>
  );
}

Sidebar is composable. The provider owns shared state; parts render semantic navigation, controls, drawers, and app-shell companions.

Sidebar props
PropWhat it doesDefault
SidebarProvider{ animate, collapsed, defaultCollapsed, onCollapsedChange, mobileOpen, defaultMobileOpen, onMobileOpenChange, side, variant, motion }Owns desktop collapsed state, mobile drawer state, side placement, visual variant, animation opt-out, and motion preset.Not set
Sidebarnav attributes + { side, variant }The labelled navigation landmark. Side and variant default from the provider but can be overridden.aria-label="Sidebar"
SidebarMenuLink{ href, current, active, disabled, external, asChild, icon, description, badge, shortcut, tooltip }Navigation item rendered as a real link by default, with aria-current and data-state hooks for current route styling. Current items render an animated selection indicator; collapsed items expose a CSS-only tooltip (string labels are used automatically, or pass tooltip), a badge dot, and an initial fallback when no icon is given.Not set
SidebarMenuAction{ label, showOnHover }Icon-sized secondary action. With showOnHover it positions itself at the end of the parent SidebarMenuItem row and reveals on row hover, focus-within, or its own focus.showOnHover: false
SidebarSeparatordiv attributesDecorative token-backed rule for dividing sidebar sections.Not set
SidebarGroup{ collapsible, open, defaultOpen, onOpenChange }Groups dense navigation sections; collapsible groups pair with SidebarGroupTrigger and hide content when closed.defaultOpen: true
SidebarTrigger / SidebarRailbutton propsDesktop collapse controls with aria-expanded state. SidebarTrigger is the default 40px navigation-strokes and directional-chevron button: place it in the sidebar header or the shell header. SidebarRail is an optional edge alternative for existing consumers. Use one primary desktop toggle.toggle collapse
SidebarMobile / SidebarMobileTrigger{ label, closeButtonLabel, showCloseButton } / button propsMobile drawer surface and trigger. The drawer uses the configured physical left/right edge in LTR and RTL, inherits text direction across its portal, and handles Escape, outside click, link activation close, and focus restore.label: "Sidebar navigation"
SidebarInset{ as?: "main" | "div" | "section" }Lightweight content companion for app shells that need Sidebar and main content to share a flex row."main"
animatebooleanEnables the CSS choreography and Motion edge-handle feedback. Set false to resolve every sidebar surface to motion none.true
motion"none" | "subtle" | "standard" | "expressive"Coordinates CSS width, disclosure, drawer, and menu transitions with the Motion-powered edge handle. Reduced-motion preferences disable transform-heavy handle movement."standard"