For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Sidebar 01

A floating workspace sidebar with search and account menus.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/sidebar-01

Replace the sample navigation and account details with your application data. Use DashboardSidebarDrawer for mobile navigation, and connect onThemeChange and onLanguageChange to your application settings. The sidebar also accepts flat and fluid options.

Source

"use client";

import {
  RiLogoutBoxRLine,
  RiNotification3Line,
  RiArrowDownSLine,
  RiExpandUpDownLine,
  RiQuestionLine,
  RiFolderLine,
  RiHomeLine,
  RiSideBarFill,
  RiTranslate2,
  RiMoonLine,
  RiSunLine,
  RiAddLine,
  RiSearchLine,
  RiSettingsLine,
  RiGroupLine,
  RiCloseLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Announcement } from "@/components/ui/announcement";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Collapsible,
  CollapsibleContent,
} from "@/components/ui/collapsible";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Empty, EmptyTitle } from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
} from "@/components/ui/sidebar";

export interface DashboardNavItem {
  key: string;
  label: string;
  icon: React.ComponentType<{ className?: string }>;
  href: string;
  /** A count shown at the end of the row while the sidebar is expanded. */
  badge?: number;
}

const defaultItems: DashboardNavItem[] = [
  { key: "overview", label: "Overview", icon: RiHomeLine, href: "/" },
  { key: "projects", label: "Projects", icon: RiFolderLine, href: "/" },
  { key: "people", label: "People", icon: RiGroupLine, href: "/" },
];

const defaultSecondaryItems = [
  { label: "Help", icon: RiQuestionLine, href: "/" },
  { label: "Settings", icon: RiSettingsLine, href: "/" },
];

const OPEN_KEY = "sidebar-01:open";
const openListeners = new Set<() => void>();
// Where the nav was scrolled to, kept for the next mount: a page change
// remounts the sidebar, and it should not jump back to the top.
let navScroll = 0;

function subscribeOpen(listener: () => void) {
  openListeners.add(listener);
  window.addEventListener("storage", listener);
  return () => {
    openListeners.delete(listener);
    window.removeEventListener("storage", listener);
  };
}

function readOpen() {
  try {
    return localStorage.getItem(OPEN_KEY) !== "closed";
  } catch {
    return true;
  }
}

function writeOpen(open: boolean) {
  try {
    localStorage.setItem(OPEN_KEY, open ? "open" : "closed");
  } catch {
    // Storage is blocked: the choice lasts until the next page load.
  }
  for (const listener of openListeners) {
    listener();
  }
}

const accountAvatar = (
  <Avatar color="primary">
    <AvatarFallback>N</AvatarFallback>
  </Avatar>
);

const linkTag = "a" as React.ElementType;

function CollapsibleLabel({
  open,
  animate,
  children,
}: {
  open: boolean;
  animate: boolean;
  children: React.ReactNode;
}) {
  return (
    <span
      aria-hidden={!open}
      className={cn(
        "flex min-w-0 items-center overflow-hidden whitespace-nowrap motion-reduce:transition-none",
        animate && "transition-all duration-300 ease-in-out",
        open
          ? "max-w-full opacity-100 blur-none"
          : "max-w-0 opacity-0 blur-xs motion-reduce:blur-none"
      )}
    >
      {children}
    </span>
  );
}

export function DashboardSidebar({
  items = defaultItems,
  secondaryItems = defaultSecondaryItems,
  selected = items[0]?.key,
  announcement = true,
  footerSlot,
  content,
  collapseNav = false,
  navInMenu = false,
  account,
  popupClassName,
  mobile = false,
  onClose,
  fluid = false,
  flat = false,
  theme = "system",
  onThemeChange,
  language = "English",
  onLanguageChange,
  linkComponent = linkTag,
}: {
  items?: DashboardNavItem[];
  /**
   * Rows under the footer slot, such as Help and Settings. Give a row an
   * `onClick` instead of an `href` to make it a button, for one that opens a
   * dialog.
   */
  secondaryItems?: (Pick<DashboardNavItem, "icon" | "label"> &
    (
      | { href: string; onClick?: undefined }
      | { href?: undefined; onClick: () => void }
    ))[];
  selected?: string;
  announcement?: boolean;
  footerSlot?: React.ReactNode;
  /** Shown under the main navigation while the sidebar is expanded, such as a tree of sessions. */
  content?: React.ReactNode;
  /** Shows only the current page, with a row to reveal the rest, to leave room for `content`. */
  collapseNav?: boolean;
  /**
   * For a page whose `content` is its own navigation: the pages leave the
   * sidebar and appear in the workspace menu instead. Searching still lists
   * them.
   */
  navInMenu?: boolean;
  /**
   * The account card at the foot. `caption` replaces the email under the
   * name. An `action`, such as an Upgrade button, sits where the chevron was.
   */
  account?: { name?: string; caption?: string; action?: React.ReactNode };
  popupClassName?: string;
  mobile?: boolean;
  onClose?: () => void;
  fluid?: boolean;
  flat?: boolean;
  theme?: "light" | "dark" | "system";
  onThemeChange?: (theme: "light" | "dark" | "system") => void;
  language?: string;
  onLanguageChange?: (language: string) => void;
  /**
   * The element used for every link, such as `next/link` for client-side
   * navigation. It must accept `href`. Defaults to a plain anchor.
   */
  linkComponent?: React.ElementType;
}) {
  const Link = linkComponent;
  // The server renders expanded. A saved choice is read after hydration.
  const open = React.useSyncExternalStore(subscribeOpen, readOpen, () => true);
  // The width and labels animate only after the person toggles the sidebar,
  // never on load or when a page change remounts it.
  const [animate, setAnimate] = React.useState(false);
  const setOpen = React.useCallback((next: boolean) => {
    setAnimate(true);
    writeOpen(next);
  }, []);
  const scrollRef = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    if (!mobile && scrollRef.current) {
      scrollRef.current.scrollTop = navScroll;
    }
  }, [mobile]);
  const expanded = mobile || open;
  const [pagesOpen, setPagesOpen] = React.useState(false);
  const [searching, setSearching] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const searchRef = React.useRef<HTMLDivElement>(null);
  const activate = React.useCallback(() => {
    setOpen(true);
    setSearching(true);
  }, [setOpen]);
  const closeSearch = React.useCallback((focus: boolean) => {
    setQuery("");
    setSearching(false);
    if (focus) {
      requestAnimationFrame(() => triggerRef.current?.focus());
    }
  }, []);
  React.useEffect(() => {
    if (searching) {
      inputRef.current?.focus();
    }
  }, [searching]);
  React.useEffect(() => {
    const shortcut = (event: KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") {
        event.preventDefault();
        activate();
      }
    };
    window.addEventListener("keydown", shortcut);
    return () => window.removeEventListener("keydown", shortcut);
  }, [activate]);
  React.useEffect(() => {
    if (!searching) {
      return;
    }
    const outside = (event: MouseEvent) => {
      if (
        event.target instanceof Node &&
        !searchRef.current?.contains(event.target)
      ) {
        closeSearch(false);
      }
    };
    document.addEventListener("click", outside);
    return () => document.removeEventListener("click", outside);
  }, [searching, closeSearch]);
  const matches = (label: string) =>
    label.toLowerCase().includes(query.trim().toLowerCase());
  const rows = [...items, ...secondaryItems];
  const navItems = items.filter(
    (item) => matches(item.label) && (!navInMenu || Boolean(query))
  );
  const splitNav = collapseNav && !query;
  const pinnedItems = splitNav
    ? navItems.filter((item) => item.key === selected)
    : navItems;
  const restItems = splitNav
    ? navItems.filter((item) => item.key !== selected)
    : [];
  const renderNavItem = ({
    key,
    label,
    href,
    icon: Icon,
    badge,
  }: (typeof items)[number]) => {
    const active = key === selected && !query;
    return (
      <SidebarMenuItem key={key}>
        <SidebarMenuButton
          isActive={active}
          render={
            <Link
              href={href}
              aria-label={label}
              aria-current={active ? "page" : undefined}
              onClick={mobile ? onClose : undefined}
            />
          }
        >
          <Icon />
          <CollapsibleLabel open={expanded} animate={animate}>
            {label}
          </CollapsibleLabel>
          {expanded && badge !== undefined && (
            <Badge
              variant="counter"
              tone="neutral"
              size="xs"
              className="ml-auto"
            >
              {badge}
            </Badge>
          )}
        </SidebarMenuButton>
      </SidebarMenuItem>
    );
  };
  return (
    <SidebarProvider
      open={open}
      onOpenChange={setOpen}
      className={cn(
        "h-full min-h-0 w-auto",
        fluid && expanded && "w-full lg:w-auto"
      )}
      style={
        {
          // The floating panel has a 1px border, so its rail is 2px wider.
          "--sidebar-width": expanded ? "260px" : flat ? "60px" : "62px",
          // The reference sidebar is the sunken surface, not the page-level sidebar token.
          "--sidebar": "var(--surface-sunken, var(--muted))",
        } as React.CSSProperties
      }
    >
      <Sidebar
        collapsible="none"
        variant={flat ? "sidebar" : "floating"}
        className={cn(
          "h-full shrink-0 overflow-hidden motion-reduce:transition-none",
          animate && "transition-all duration-300 ease-in-out",
          fluid && expanded && "w-full lg:w-65"
        )}
      >
        <SidebarHeader className="pb-0">
          <div
            className={
              expanded
                ? "flex items-center justify-between"
                : "flex flex-col-reverse items-center gap-2.5"
            }
          >
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    type="button"
                    variant="unstyled"
                    shape="pill"
                    className="min-w-0 items-center gap-2"
                  />
                }
                aria-label="People with access"
              >
                <Avatar>
                  <AvatarFallback>A</AvatarFallback>
                </Avatar>
                {expanded && (
                  <>
                    <span className="truncate text-sm font-medium">
                      Alex Morgan
                    </span>
                    <RiExpandUpDownLine className="size-4 shrink-0 text-muted-foreground/60" />
                  </>
                )}
              </DropdownMenuTrigger>
              <DropdownMenuContent
                side="right"
                sideOffset={8}
                className={popupClassName}
              >
                <DropdownMenuGroup>
                  <DropdownMenuLabel>People with access</DropdownMenuLabel>
                  <DropdownMenuItem>Alex Morgan</DropdownMenuItem>
                  <DropdownMenuItem>Sam Rivera</DropdownMenuItem>
                  <DropdownMenuItem>Taylor Kim</DropdownMenuItem>
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuGroup>
                  <DropdownMenuItem>
                    <RiAddLine /> Add person
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <RiSettingsLine /> Manage
                  </DropdownMenuItem>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
            <Button
              variant="ghost-muted"
              size="icon-2xs"
              type="button"
              tooltip={
                mobile
                  ? "Close sidebar"
                  : open
                    ? "Collapse sidebar"
                    : "Expand sidebar"
              }
              aria-expanded={mobile ? undefined : open}
              onClick={() => {
                if (mobile) {
                  onClose?.();
                  return;
                }
                if (open) {
                  closeSearch(false);
                }
                setOpen(!open);
              }}
            >
              {mobile ? (
                <RiCloseLine className="size-5" />
              ) : (
                <RiSideBarFill
                  className={cn(
                    "size-5 text-muted-foreground",
                    expanded && "-scale-x-100 rtl:scale-x-100"
                  )}
                />
              )}
            </Button>
          </div>
          <div ref={searchRef}>
            {searching && expanded ? (
              <div className="flex h-9 items-center gap-2 rounded-full bg-muted px-2 ring-2 ring-inset ring-border">
                <RiSearchLine className="size-5 shrink-0 text-muted-foreground" />
                <Input
                  ref={inputRef}
                  type="search"
                  variant="ghost"
                  aria-label="Filter navigation"
                  value={query}
                  onChange={(event) => setQuery(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") {
                      event.preventDefault();
                      closeSearch(true);
                    }
                  }}
                  placeholder="Search navigation…"
                  className="h-auto flex-1 [&::-webkit-search-cancel-button]:appearance-none"
                />
                <Button
                  variant="close"
                  size="icon-2xs"
                  type="button"
                  aria-label="Clear navigation search"
                  onClick={() => closeSearch(true)}
                >
                  <RiCloseLine className="size-4" />
                </Button>
              </div>
            ) : (
              <Button
                variant="muted"
                shape="pill"
                ref={triggerRef}
                type="button"
                onClick={activate}
                aria-label="Search navigation"
                // oxlint-disable-next-line shadcn/no-restyle -- reference (light): the search field is one step darker than the sunken sidebar and one more on hover; dark keeps the muted fill
                className="flex w-full justify-start gap-2 overflow-hidden not-dark:bg-surface-strong not-dark:hover:bg-(--badge-neutral-background)"
              >
                <RiSearchLine
                  data-icon="inline-start"
                  className="size-5 shrink-0"
                />
                {expanded && (
                  <>
                    <span className="flex-1 text-left text-sm">
                      Quick Search
                    </span>
                    <Kbd>⌘L</Kbd>
                  </>
                )}
              </Button>
            )}
          </div>
        </SidebarHeader>
        <SidebarContent
          ref={scrollRef}
          onScroll={(event) => {
            if (!mobile) {
              navScroll = event.currentTarget.scrollTop;
            }
          }}
        >
          <div className="flex flex-col gap-3 px-3 py-3">
            <nav aria-label="Main navigation" className="px-0.5">
              <Collapsible open={pagesOpen} onOpenChange={setPagesOpen}>
                <SidebarMenu>{pinnedItems.map(renderNavItem)}</SidebarMenu>
                {restItems.length > 0 && (
                  <CollapsibleContent>
                    <div className="pt-1">
                      <SidebarMenu>{restItems.map(renderNavItem)}</SidebarMenu>
                    </div>
                  </CollapsibleContent>
                )}
                {collapseNav &&
                  !navInMenu &&
                  !query &&
                  expanded &&
                  items.length > 1 && (
                    <Button
                      variant="text"
                      radius={10}
                      size="sm"
                      type="button"
                      aria-expanded={pagesOpen}
                      onClick={() => setPagesOpen(!pagesOpen)}
                      className="mt-1 w-full justify-start gap-2"
                    >
                      <RiArrowDownSLine
                        className={cn(
                          "size-4 transition-transform duration-200 motion-reduce:transition-none",
                          pagesOpen && "rotate-180"
                        )}
                      />
                      {pagesOpen ? "Fewer pages" : "All pages"}
                    </Button>
                  )}
              </Collapsible>
            </nav>
            {expanded && content}
            {rows.every((row) => !matches(row.label)) && expanded && (
              <Empty variant="ghost">
                <EmptyTitle>No results</EmptyTitle>
              </Empty>
            )}
          </div>
        </SidebarContent>
        <SidebarFooter className="shrink-0 pt-0">
          {expanded && announcement && (
            <Announcement
              title="A new workspace view"
              description="Organize your work in one place."
              actionLabel="Explore"
              dismissible
            />
          )}
          {expanded && footerSlot}
          <nav aria-label="More navigation" className="px-0.5">
            <SidebarMenu>
              {secondaryItems
                .filter((row) => matches(row.label))
                .map(({ label, href, onClick, icon: Icon }) => (
                  <SidebarMenuItem key={label}>
                    <SidebarMenuButton
                      {...(href === undefined
                        ? {
                            "aria-label": label,
                            onClick: () => {
                              if (mobile) {
                                onClose?.();
                              }
                              onClick();
                            },
                          }
                        : {
                            render: (
                              <Link
                                href={href}
                                aria-label={label}
                                onClick={mobile ? onClose : undefined}
                              />
                            ),
                          })}
                    >
                      <Icon />
                      <CollapsibleLabel open={expanded} animate={animate}>
                        {label}
                      </CollapsibleLabel>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                ))}
            </SidebarMenu>
          </nav>
          <div
            className={cn(
              account?.action && expanded
                ? "flex w-full items-center gap-2 rounded-[12px] bg-muted p-2"
                : "contents"
            )}
          >
            <DropdownMenu>
              <DropdownMenuTrigger
                aria-label="Workspace menu"
                surface={expanded && !account?.action ? "tile" : "none"}
                render={
                  expanded ? (
                    <Button
                      type="button"
                      variant="unstyled"
                      radius={account?.action ? 8 : undefined}
                      className={cn(
                        "group justify-start gap-2 text-left",
                        account?.action ? "min-w-0 flex-1" : "w-full"
                      )}
                    />
                  ) : (
                    <Button
                      type="button"
                      variant="ghost"
                      size="icon"
                      shape="pill"
                    />
                  )
                }
              >
                {accountAvatar}
                {expanded && (
                  <>
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-sm font-medium">
                        {account?.name ?? "Northstar Studio"}
                      </span>
                      <span className="block truncate text-sm text-muted-foreground">
                        {account?.caption ?? "hello@example.com"}
                      </span>
                    </span>
                    {!account?.action && (
                      <span className="flex size-5 shrink-0 items-center justify-center rounded-[6px] bg-foreground/10 text-muted-foreground">
                        <RiArrowDownSLine className="size-4 transition-transform duration-150 ease-out group-aria-expanded:rotate-180 motion-reduce:transition-none" />
                      </span>
                    )}
                  </>
                )}
              </DropdownMenuTrigger>
              <DropdownMenuContent
                side="right"
                align="end"
                sideOffset={8}
                className={popupClassName}
              >
                {navInMenu && (
                  <>
                    <DropdownMenuGroup>
                      <DropdownMenuLabel>Pages</DropdownMenuLabel>
                      {items.map(({ key, label, href, icon: Icon }) => (
                        <DropdownMenuItem
                          key={key}
                          render={<Link href={href} />}
                          onClick={mobile ? onClose : undefined}
                        >
                          <Icon /> {label}
                        </DropdownMenuItem>
                      ))}
                    </DropdownMenuGroup>
                    <DropdownMenuSeparator />
                  </>
                )}
                <div className="flex items-center gap-2 p-2">
                  {accountAvatar}
                  <span className="min-w-0 flex-1">
                    <span className="block truncate text-sm leading-5 font-medium">
                      {account?.name ?? "Northstar Studio"}
                    </span>
                    <span className="block truncate text-sm leading-5 text-muted-foreground">
                      {account?.caption ?? "hello@example.com"}
                    </span>
                  </span>
                </div>
                <DropdownMenuGroup>
                  <DropdownMenuItem>
                    <RiHomeLine /> Workspace profile
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <RiFolderLine /> Projects
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <RiGroupLine /> People
                  </DropdownMenuItem>
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Personal</DropdownMenuLabel>
                  <DropdownMenuItem>
                    <RiNotification3Line /> Notifications
                    <Badge variant="counter" tone="neutral" className="ml-auto">
                      5
                    </Badge>
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <RiSettingsLine /> Account settings
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    onClick={() =>
                      onThemeChange?.(theme === "dark" ? "light" : "dark")
                    }
                  >
                    {theme === "dark" ? <RiMoonLine /> : <RiSunLine />}{" "}
                    Appearance: {theme}
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    onClick={() =>
                      onLanguageChange?.(
                        language === "English" ? "Français" : "English"
                      )
                    }
                  >
                    <RiTranslate2 /> Language: {language}
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <RiLogoutBoxRLine /> Sign out
                  </DropdownMenuItem>
                </DropdownMenuGroup>
                <div className="flex items-center justify-between p-2 text-sm leading-5 text-muted-foreground">
                  Cobalt
                  <Badge variant="counter" tone="soft">
                    v1.0.0
                  </Badge>
                </div>
              </DropdownMenuContent>
            </DropdownMenu>
            {expanded && account?.action}
          </div>
        </SidebarFooter>
      </Sidebar>
    </SidebarProvider>
  );
}

/** Modal navigation for narrow screens. */
export function DashboardSidebarDrawer({
  open,
  onOpenChange,
  popupClassName,
  ...props
}: Omit<React.ComponentProps<typeof DashboardSidebar>, "mobile" | "onClose"> & {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent
        side="left"
        variant="ghost"
        showCloseButton={false}
        className={cn("w-auto! max-w-none!", popupClassName)}
      >
        <SheetTitle className="sr-only">Navigation</SheetTitle>
        <DashboardSidebar
          {...props}
          popupClassName={popupClassName}
          mobile
          onClose={() => onOpenChange(false)}
        />
      </SheetContent>
    </Sheet>
  );
}