Skip to content
← Blocks

Rail Sidebar

A compact icon rail with workspace and account popouts.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/rail-sidebar.json

Use it

Scroll horizontally for long lines
"use client";
import { RailSidebar } from "@/components/jez-ui/blocks/rail-sidebar";

export default function Example() {
  return (
    <>
      <RailSidebar />
    </>
  );
}

Compose your own

These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.

Scroll horizontally for long lines
"use client";
import { Archive, Folder, Settings, Square } from "lucide-react";
import {
  RailSidebarRoot,
  RailSidebarPanel,
  RailSidebarHeader,
  RailSidebarBody,
  RailSidebarFooter,
  RailSidebarContent,
  RailSidebarTrigger,
} from "@/components/jez-ui/blocks/rail-sidebar";
import {
  SidebarGroup,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
} from "@/components/jez-ui/ui/sidebar";
export default function Example() {
  return (
    <RailSidebarRoot>
      <RailSidebarPanel>
        <RailSidebarHeader className="flex-row items-center gap-2">
          <Square className="size-4 shrink-0" aria-hidden="true" />
          <span className="group-data-[state=collapsed]/sidebar:sr-only">
            Our studio
          </span>
        </RailSidebarHeader>
        <RailSidebarBody>
          <SidebarGroup>
            <SidebarGroupLabel>Projects</SidebarGroupLabel>
            <SidebarMenu>
              {["Work", "Archive"].map((label) => (
                <SidebarMenuItem key={label}>
                  <SidebarMenuButton asChild>
                    <a href={"#" + label.toLowerCase()}>
                      {label === "Work" ? (
                        <Folder aria-hidden="true" />
                      ) : (
                        <Archive aria-hidden="true" />
                      )}
                      <span>{label}</span>
                    </a>
                  </SidebarMenuButton>
                </SidebarMenuItem>
              ))}
            </SidebarMenu>
          </SidebarGroup>
        </RailSidebarBody>
        <RailSidebarFooter>
          <SidebarMenuButton asChild>
            <a href="#settings">
              <Settings aria-hidden="true" />
              <span>Studio settings</span>
            </a>
          </SidebarMenuButton>
        </RailSidebarFooter>
      </RailSidebarPanel>
      <RailSidebarContent>
        <header className="flex items-center gap-3 border-b border-border p-4">
          <RailSidebarTrigger />
          Your workspace
        </header>
        <main className="p-6">Your own page content.</main>
      </RailSidebarContent>
    </RailSidebarRoot>
  );
}
Scroll horizontally for long lines
import { RailSidebar, RailSidebarRoot, RailSidebarPanel, RailSidebarHeader, RailSidebarBody, RailSidebarFooter, RailSidebarContent, RailSidebarTrigger } from "@/components/jez-ui/blocks/rail-sidebar";
Read the composition guide →

Props & types

Scroll horizontally for long lines
Omit<React.ComponentProps<typeof SidebarLayout>, "variant">

Source

View 6 source files

registry/blocks/rail-sidebar.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { SidebarLayout } from "./sidebar-layout";
export function RailSidebar(
  props: Omit<React.ComponentProps<typeof SidebarLayout>, "variant">,
) {
  return <SidebarLayout variant="rail" {...props} />;
}

import {
  SidebarProvider,
  Sidebar,
  SidebarHeader,
  SidebarContent,
  SidebarFooter,
  SidebarInset,
  SidebarTrigger,
} from "../ui/sidebar";
import { cn } from "../ui/utils";
export function RailSidebarRoot({
  className,
  ...props
}: React.ComponentProps<typeof SidebarProvider>) {
  return (
    <SidebarProvider
      defaultOpen={false}
      className={cn(
        "min-h-[620px] overflow-hidden rounded-xl border border-border",
        className,
      )}
      {...props}
    />
  );
}
export function RailSidebarPanel(props: React.ComponentProps<typeof Sidebar>) {
  return <Sidebar variant="sidebar" collapsible="icon" {...props} />;
}
export function RailSidebarHeader(
  props: React.ComponentProps<typeof SidebarHeader>,
) {
  return <SidebarHeader {...props} />;
}
export function RailSidebarBody(
  props: React.ComponentProps<typeof SidebarContent>,
) {
  return <SidebarContent {...props} />;
}
export function RailSidebarFooter(
  props: React.ComponentProps<typeof SidebarFooter>,
) {
  return <SidebarFooter {...props} />;
}
export function RailSidebarContent(
  props: React.ComponentProps<typeof SidebarInset>,
) {
  return <SidebarInset {...props} />;
}
export function RailSidebarTrigger(
  props: React.ComponentProps<typeof SidebarTrigger>,
) {
  return <SidebarTrigger {...props} />;
}

registry/blocks/sidebar-layout.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import {
  Home,
  Inbox,
  Folder,
  Users,
  Settings,
  PanelLeftClose,
  Menu,
  X,
  Search,
  Plus,
  ArrowUpRight,
} from "lucide-react";
import {
  OrganizationSwitcher,
  type Organization,
} from "./organization-switcher";
import { UserSwitcher, type SwitcherAccount } from "./user-switcher";
import { SidebarProvider } from "../ui/sidebar";
import { cn } from "../ui/utils";
const links = [
  { name: "Overview", icon: Home },
  { name: "Inbox", icon: Inbox },
  { name: "Projects", icon: Folder },
  { name: "Team", icon: Users },
  { name: "Settings", icon: Settings },
];
export function SidebarLayout({
  sidebar,
  organizations,
  accounts,
  onOrganizationChange,
  onAccountChange,
  onCreateOrganization,
  onAddAccount,
  onSignOut,
  variant = "workspace",
  className,
  children,
  onNavigate,
  ...rootProps
}: React.ComponentProps<"div"> & {
  sidebar?: React.ReactNode;
  organizations?: Organization[];
  accounts?: SwitcherAccount[];
  onOrganizationChange?: (id: string) => void;
  onAccountChange?: (id: string) => void;
  onCreateOrganization?: () => void;
  onAddAccount?: () => void;
  onSignOut?: () => void;
  variant?: "workspace" | "rail" | "inset";
  className?: string;
  children?: React.ReactNode;
  onNavigate?: (name: string) => void;
}) {
  const [current, setCurrent] = React.useState("Overview"),
    [mobile, setMobile] = React.useState(false),
    [collapsed, setCollapsed] = React.useState(false),
    [query, setQuery] = React.useState("");
  const rail = variant === "rail" || collapsed;
  return (
    <SidebarProvider
      open={!collapsed}
      onOpenChange={(open) => setCollapsed(!open)}
      openMobile={mobile}
      onOpenMobileChange={setMobile}
      className="contents"
    >
      <div
        {...rootProps}
        className={cn(
          "relative flex min-h-[620px] overflow-hidden rounded-xl border border-border bg-muted/50",
          variant === "inset" && "md:p-2",
          className,
        )}
      >
        <aside
          data-state={rail && !mobile ? "collapsed" : "expanded"}
          className={cn(
            "group/sidebar absolute inset-y-0 left-0 z-30 flex flex-col border-r border-border bg-background p-3 md:relative md:flex",
            mobile ? "flex w-64 shadow-xl" : "hidden",
            rail ? "md:w-[76px]" : "md:w-60",
            variant === "inset" && "md:border-0 md:bg-transparent",
          )}
        >
          {sidebar ?? (
            <>
              <OrganizationSwitcher
                items={organizations}
                onValueChange={onOrganizationChange}
                onCreate={onCreateOrganization}
                compact={rail && !mobile}
              />
              <button
                aria-label="Close sidebar"
                onClick={() => setMobile(false)}
                className="absolute right-1 top-1 rounded bg-background p-1 md:hidden"
              >
                <X size={14} />
              </button>
              {!rail && (
                <label className="mt-5 flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-2">
                  <Search
                    size={14}
                    className="shrink-0 text-muted-foreground"
                  />
                  <input
                    aria-label="Filter navigation"
                    value={query}
                    onChange={(e) => setQuery(e.target.value)}
                    placeholder="Find a page…"
                    className="min-w-0 bg-transparent text-xs outline-none"
                  />
                </label>
              )}
              <nav aria-label="Workspace pages" className="mt-6 grid gap-1">
                {links
                  .filter(
                    (l) =>
                      rail ||
                      l.name.toLowerCase().includes(query.toLowerCase()),
                  )
                  .map(({ name, icon: Icon }) => (
                    <button
                      key={name}
                      aria-label={name}
                      title={rail ? name : undefined}
                      aria-current={current === name ? "page" : undefined}
                      onClick={() => {
                        setCurrent(name);
                        setMobile(false);
                        onNavigate?.(name);
                      }}
                      className={cn(
                        "flex items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors",
                        rail && "justify-center px-2",
                        current === name
                          ? "bg-primary/10 font-medium text-primary"
                          : "text-muted-foreground hover:bg-muted hover:text-foreground",
                      )}
                    >
                      <Icon size={17} />
                      {!rail && (
                        <>
                          <span className="flex-1">{name}</span>
                          {name === "Inbox" && (
                            <span className="text-xs">4</span>
                          )}
                        </>
                      )}
                    </button>
                  ))}
                {!rail &&
                  !links.some((l) =>
                    l.name.toLowerCase().includes(query.toLowerCase()),
                  ) && (
                    <p className="p-3 text-xs text-muted-foreground">
                      No pages found.
                    </p>
                  )}
              </nav>
              {!rail && (
                <div className="mt-8 px-3">
                  <p className="mb-3 text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
                    Pinned projects
                  </p>
                  {["Website refresh", "Brand guidelines"].map((name, i) => (
                    <button
                      key={name}
                      onClick={() => {
                        setCurrent(name);
                        setMobile(false);
                        onNavigate?.(name);
                      }}
                      className="flex w-full items-center gap-2 py-2 text-left text-xs text-muted-foreground hover:text-foreground"
                    >
                      <span
                        className={cn(
                          "size-1.5 rounded-sm",
                          i ? "bg-[#b98d70]" : "bg-[#8c9d7a]",
                        )}
                      />
                      {name}
                    </button>
                  ))}
                </div>
              )}
              <div className="mt-auto border-t border-border pt-3">
                <UserSwitcher
                  items={accounts}
                  onValueChange={onAccountChange}
                  onAddAccount={onAddAccount}
                  onSignOut={onSignOut}
                  compact={rail && !mobile}
                />
              </div>
            </>
          )}
        </aside>
        {mobile && (
          <button
            aria-label="Dismiss sidebar"
            onClick={() => setMobile(false)}
            className="absolute inset-0 z-20 bg-black/25 md:hidden"
          />
        )}
        <div
          className={cn(
            "min-w-0 flex-1 bg-background",
            variant === "inset" && "rounded-lg border border-border shadow-sm",
          )}
        >
          <header className="flex h-16 items-center gap-3 border-b border-border px-5">
            <button
              aria-label="Open sidebar"
              onClick={() => setMobile(true)}
              className="p-1 md:hidden"
            >
              <Menu size={18} />
            </button>
            <button
              aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
              onClick={() => setCollapsed(!collapsed)}
              className="hidden rounded p-1 text-muted-foreground hover:bg-muted md:block"
            >
              <PanelLeftClose size={17} />
            </button>
            <span className="text-xs text-muted-foreground">Workspace</span>
            <span className="text-border">/</span>
            <span className="truncate text-sm">{current}</span>
          </header>
          <main className="p-6 md:p-8">
            {children ?? (
              <>
                <div className="flex flex-wrap items-center justify-between gap-3">
                  <div>
                    <p className="text-xs text-muted-foreground">
                      Your workspace, at a glance
                    </p>
                    <h1 className="mt-2 font-display text-2xl tracking-tight">
                      {current === "Overview"
                        ? "Make room for good work."
                        : current}
                    </h1>
                  </div>
                  <button
                    onClick={() => {
                      setCurrent("New project");
                      onNavigate?.("New project");
                    }}
                    className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs hover:bg-muted"
                  >
                    <Plus size={14} />
                    New project
                  </button>
                </div>
                {current === "New project" ? (
                  <form
                    className="mt-8 grid max-w-sm gap-4"
                    onSubmit={(e) => {
                      e.preventDefault();
                      const name = new FormData(e.currentTarget)
                        .get("name")
                        ?.toString()
                        .trim();
                      if (name) setCurrent(name);
                    }}
                  >
                    <label className="grid gap-2 text-sm">
                      Project name
                      <input
                        name="name"
                        required
                        className="rounded-lg border border-border bg-background p-3"
                        placeholder="Untitled project"
                      />
                    </label>
                    <button className="rounded-lg bg-primary p-3 text-sm text-primary-foreground">
                      Create project
                    </button>
                  </form>
                ) : (
                  <>
                    <p className="mt-3 max-w-lg text-sm leading-relaxed text-muted-foreground">
                      A clear view of the projects and conversations moving your
                      team forward.
                    </p>
                    <div className="mt-9 divide-y divide-border border-y border-border">
                      {[
                        "Website refresh",
                        "Brand guidelines",
                        "Component library",
                      ].map((name, i) => (
                        <button
                          key={name}
                          onClick={() => {
                            setCurrent(name);
                            onNavigate?.(name);
                          }}
                          className="flex w-full items-center gap-4 py-5 text-left hover:bg-muted/40"
                        >
                          <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-xs">
                            0{i + 1}
                          </span>
                          <span className="min-w-0 flex-1">
                            <span className="block text-sm font-medium">
                              {name}
                            </span>
                            <span className="mt-1 block text-xs text-muted-foreground">
                              {
                                [
                                  "12 tasks · In progress",
                                  "8 tasks · In review",
                                  "24 tasks · Planning",
                                ][i]
                              }
                            </span>
                          </span>
                          <ArrowUpRight
                            size={16}
                            className="text-muted-foreground"
                          />
                        </button>
                      ))}
                    </div>
                  </>
                )}
              </>
            )}
          </main>
        </div>
      </div>
    </SidebarProvider>
  );
}

registry/blocks/organization-switcher.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "../ui/utils";
import { DropdownMenu as D } from "radix-ui";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
export type Organization = { id: string; name: string; plan: string };
const defaults: Organization[] = [
  { id: "common", name: "Common Studio", plan: "Team workspace" },
  { id: "personal", name: "Personal", plan: "Free workspace" },
  { id: "fieldwork", name: "Fieldwork", plan: "Team workspace" },
];
export type OrganizationSwitcherOptions = {
  items?: Organization[];
  onValueChange?: (id: string) => void;
  onCreate?: () => void;
  compact?: boolean;
};
export type OrganizationSwitcherProps = Omit<
  React.ComponentProps<"div">,
  keyof OrganizationSwitcherOptions
> &
  OrganizationSwitcherOptions;
function useOrganizationSwitcherModel({
  items = defaults,
  onValueChange,
  onCreate,
  compact = false,
  children,
  ...rootProps
}: OrganizationSwitcherProps) {
  const [value, setValue] = React.useState(items[0]?.id);
  const [notice, setNotice] = React.useState("");
  const active = items.find((i) => i.id === value) ?? items[0];
  if (!active) return null;
  return {
    items,
    onValueChange,
    onCreate,
    compact,
    children,
    rootProps,
    value,
    setValue,
    notice,
    setNotice,
    active,
  };
}
const OrganizationSwitcherCompositionContext = React.createContext<ReturnType<
  typeof useOrganizationSwitcherModel
> | null>(null);
function useOrganizationSwitcherComposition() {
  const context = React.useContext(OrganizationSwitcherCompositionContext);
  if (!context)
    throw new Error(
      "OrganizationSwitcher parts must be inside OrganizationSwitcher.",
    );
  return context;
}
export function OrganizationSwitcher(props: OrganizationSwitcherProps) {
  const model = useOrganizationSwitcherModel(props);
  if (!model) return null;
  const { rootProps, children } = model;
  return (
    <OrganizationSwitcherCompositionContext.Provider value={model}>
      <div {...rootProps}>
        {children !== undefined ? (
          children
        ) : (
          <>
            <OrganizationSwitcherMenu />
            <OrganizationSwitcherStatus />
          </>
        )}
      </div>
    </OrganizationSwitcherCompositionContext.Provider>
  );
}

export function OrganizationSwitcherMenu({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Root>> & {
  children?: React.ReactNode;
}) {
  return (
    <D.Root {...props}>
      {children === undefined ? (
        <>
          <OrganizationSwitcherTrigger />
          <OrganizationSwitcherPopup />
        </>
      ) : (
        children
      )}
    </D.Root>
  );
}
export function OrganizationSwitcherStatus({
  children,
}: React.PropsWithChildren) {
  const { notice } = useOrganizationSwitcherComposition();
  return children === undefined
    ? notice && (
        <p role="status" className="p-2 text-xs">
          {notice}
        </p>
      )
    : children;
}

export function OrganizationSwitcherTrigger({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Trigger>> & {
  children?: React.ReactNode;
}) {
  const { compact, active } = useOrganizationSwitcherComposition();
  return (
    <D.Trigger
      aria-label={`Workspace: ${active.name}`}
      {...props}
      className={cn(
        "flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-muted",
        props.className,
      )}
    >
      {children === undefined ? (
        <>
          <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-primary font-display text-lg text-primary-foreground">
            {active.name[0]}
          </span>
          {!compact && (
            <>
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm font-semibold">
                  {active.name}
                </span>
                <span className="block text-xs text-muted-foreground">
                  {active.plan}
                </span>
              </span>
              <ChevronsUpDown size={14} />
            </>
          )}
        </>
      ) : (
        children
      )}
    </D.Trigger>
  );
}
export function OrganizationSwitcherOptions({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.RadioGroup>> & {
  children?: React.ReactNode;
}) {
  const { items, onValueChange, setValue, active } =
    useOrganizationSwitcherComposition();
  return (
    <D.RadioGroup
      value={active.id}
      {...props}
      onValueChange={(value) => {
        ((id) => {
          setValue(id);
          onValueChange?.(id);
        })(value);
        props.onValueChange?.(value);
      }}
    >
      {children === undefined
        ? items.map((item) => (
            <D.RadioItem
              key={item.id}
              value={item.id}
              className="flex items-center justify-between gap-3 rounded-md p-3 text-sm outline-none data-[highlighted]:bg-muted"
            >
              <span>
                {item.name}
                <span className="mt-1 block text-xs text-muted-foreground">
                  {item.plan}
                </span>
              </span>
              <D.ItemIndicator>
                <Check size={14} />
              </D.ItemIndicator>
            </D.RadioItem>
          ))
        : children}
    </D.RadioGroup>
  );
}
export function OrganizationSwitcherPopup({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Portal>> & {
  children?: React.ReactNode;
}) {
  const { onCreate, setNotice } = useOrganizationSwitcherComposition();
  return (
    <D.Portal {...props}>
      {children === undefined ? (
        <D.Content
          sideOffset={8}
          align="start"
          collisionPadding={12}
          className="jez-popover z-50 w-64 rounded-xl border border-border bg-background p-1.5 text-foreground shadow-xl"
        >
          <D.Label className="px-3 py-2 text-xs text-muted-foreground">
            Your workspaces
          </D.Label>
          <OrganizationSwitcherOptions />
          <D.Separator className="my-1 h-px bg-border" />
          <D.Item
            onSelect={() =>
              onCreate
                ? onCreate()
                : setNotice("Demo: connect your workspace creation flow.")
            }
            className="flex items-center gap-2 rounded-md p-3 text-sm outline-none data-[highlighted]:bg-muted"
          >
            <Plus size={15} />
            Create workspace
          </D.Item>
        </D.Content>
      ) : (
        children
      )}
    </D.Portal>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/blocks/user-switcher.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { DropdownMenu as D } from "radix-ui";
import { Check, ChevronsUpDown, Plus, Settings, LogOut } from "lucide-react";
import { cn } from "../ui/utils";
export type SwitcherAccount = {
  id: string;
  name: string;
  detail: string;
  initials: string;
  color?: string;
};
const accounts: SwitcherAccount[] = [
  {
    id: "alex",
    name: "Alex Rivers",
    detail: "alex@common.example",
    initials: "AR",
    color: "#d5dec9",
  },
  {
    id: "jamie",
    name: "Jamie Chen",
    detail: "jamie@common.example",
    initials: "JC",
    color: "#e2cfc0",
  },
];
export type UserSwitcherOptions = {
  items?: SwitcherAccount[];
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  onAddAccount?: () => void;
  onSignOut?: () => void;
  settingsHref?: string;
  className?: string;
  compact?: boolean;
};
export type UserSwitcherProps = Omit<
  React.ComponentProps<"div">,
  keyof UserSwitcherOptions
> &
  UserSwitcherOptions;
function useUserSwitcherModel({
  items = accounts,
  defaultValue = items[0]?.id,
  onValueChange,
  onAddAccount,
  onSignOut,
  settingsHref = "/templates/projects/preview/settings",
  className,
  compact = false,
  children,
  ...rootProps
}: UserSwitcherProps) {
  const [value, setValue] = React.useState(defaultValue);
  const [notice, setNotice] = React.useState("");
  const active = items.find((i) => i.id === value) ?? items[0];
  if (!active) return null;
  return {
    items,
    defaultValue,
    onValueChange,
    onAddAccount,
    onSignOut,
    settingsHref,
    className,
    compact,
    children,
    rootProps,
    value,
    setValue,
    notice,
    setNotice,
    active,
  };
}
const UserSwitcherCompositionContext = React.createContext<ReturnType<
  typeof useUserSwitcherModel
> | null>(null);
function useUserSwitcherComposition() {
  const context = React.useContext(UserSwitcherCompositionContext);
  if (!context)
    throw new Error("UserSwitcher parts must be inside UserSwitcher.");
  return context;
}
export function UserSwitcher(props: UserSwitcherProps) {
  const model = useUserSwitcherModel(props);
  if (!model) return null;
  const { className, rootProps, children } = model;
  return (
    <UserSwitcherCompositionContext.Provider value={model}>
      <div {...rootProps} className={cn("relative w-full max-w-xs", className)}>
        {children !== undefined ? (
          children
        ) : (
          <>
            <UserSwitcherMenu />
            <UserSwitcherStatus />
          </>
        )}
      </div>
    </UserSwitcherCompositionContext.Provider>
  );
}

export function UserSwitcherMenu({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Root>> & {
  children?: React.ReactNode;
}) {
  return (
    <D.Root {...props}>
      {children === undefined ? (
        <>
          <UserSwitcherTrigger />
          <UserSwitcherPopup />
        </>
      ) : (
        children
      )}
    </D.Root>
  );
}
export function UserSwitcherStatus({ children }: React.PropsWithChildren) {
  const { notice } = useUserSwitcherComposition();
  return children === undefined
    ? notice && (
        <p role="status" className="px-2 pt-2 text-xs text-muted-foreground">
          {notice}
        </p>
      )
    : children;
}

export function UserSwitcherTrigger({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Trigger>> & {
  children?: React.ReactNode;
}) {
  const { compact, active } = useUserSwitcherComposition();
  return (
    <D.Trigger
      aria-label={`Account: ${active.name}`}
      {...props}
      className={cn(
        "flex w-full items-center gap-3 rounded-lg p-2 text-left hover:bg-muted focus-visible:outline-2 focus-visible:outline-primary",
        props.className,
      )}
    >
      {children === undefined ? (
        <>
          <span
            style={{ background: active.color ?? "#d5dec9" }}
            className="grid size-9 shrink-0 place-items-center rounded-full text-xs font-semibold text-[#263326]"
          >
            {active.initials}
          </span>
          {!compact && (
            <>
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm font-medium">
                  {active.name}
                </span>
                <span className="block truncate text-xs text-muted-foreground">
                  {active.detail}
                </span>
              </span>
              <ChevronsUpDown
                size={14}
                className="shrink-0 text-muted-foreground"
              />
            </>
          )}
        </>
      ) : (
        children
      )}
    </D.Trigger>
  );
}
export function UserSwitcherOptions({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.RadioGroup>> & {
  children?: React.ReactNode;
}) {
  const { items, onValueChange, setValue, active } =
    useUserSwitcherComposition();
  return (
    <D.RadioGroup
      value={active.id}
      {...props}
      onValueChange={(value) => {
        ((id) => {
          setValue(id);
          onValueChange?.(id);
        })(value);
        props.onValueChange?.(value);
      }}
    >
      {children === undefined
        ? items.map((item) => (
            <D.RadioItem
              key={item.id}
              value={item.id}
              className="flex cursor-default items-center gap-3 rounded-md px-3 py-2.5 outline-none data-[highlighted]:bg-muted"
            >
              <span
                style={{ background: item.color ?? "#d5dec9" }}
                className="grid size-8 shrink-0 place-items-center rounded-full text-xs text-[#263326]"
              >
                {item.initials}
              </span>
              <span className="min-w-0 flex-1">
                <span className="block truncate text-sm">{item.name}</span>
                <span className="block truncate text-xs text-muted-foreground">
                  {item.detail}
                </span>
              </span>
              <D.ItemIndicator>
                <Check size={14} />
              </D.ItemIndicator>
            </D.RadioItem>
          ))
        : children}
    </D.RadioGroup>
  );
}
export function UserSwitcherPopup({
  children,
  ...props
}: Partial<React.ComponentProps<typeof D.Portal>> & {
  children?: React.ReactNode;
}) {
  const { onAddAccount, onSignOut, settingsHref, setNotice } =
    useUserSwitcherComposition();
  return (
    <D.Portal {...props}>
      {children === undefined ? (
        <D.Content
          side="top"
          align="start"
          sideOffset={8}
          collisionPadding={12}
          className="jez-popover z-50 w-72 max-w-[calc(100vw-24px)] rounded-xl border border-border bg-background p-1.5 text-foreground shadow-xl"
        >
          <D.Label className="px-3 py-2 text-xs text-muted-foreground">
            Switch account
          </D.Label>
          <UserSwitcherOptions />
          <D.Separator className="my-1 h-px bg-border" />
          <D.Item
            onSelect={() =>
              onAddAccount
                ? onAddAccount()
                : setNotice("Demo: connect your account sign-in flow.")
            }
            className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-none data-[highlighted]:bg-muted"
          >
            <Plus size={15} />
            Add an account
          </D.Item>
          <D.Item asChild>
            <a
              href={settingsHref}
              className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-none data-[highlighted]:bg-muted"
            >
              <Settings size={15} />
              Account settings
            </a>
          </D.Item>
          <D.Item
            onSelect={() =>
              onSignOut
                ? onSignOut()
                : setNotice("Demo: connect your sign-out handler.")
            }
            className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-danger outline-none data-[highlighted]:bg-muted"
          >
            <LogOut size={15} />
            Sign out
          </D.Item>
        </D.Content>
      ) : (
        children
      )}
    </D.Portal>
  );
}

registry/ui/sidebar.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Dialog, Slot } from "radix-ui";
import { PanelLeft, X } from "lucide-react";
import { cn } from "./utils";
type SidebarState = {
  triggerRef: React.RefObject<HTMLElement | null>;
  open: boolean;
  setOpen: (open: boolean) => void;
  openMobile: boolean;
  setOpenMobile: (open: boolean) => void;
  isMobile: boolean;
  toggleSidebar: () => void;
};
const Context = React.createContext<SidebarState | null>(null);
export function useSidebar() {
  const value = React.useContext(Context);
  if (!value) throw new Error("Sidebar components require SidebarProvider.");
  return value;
}
export function SidebarProvider({
  children,
  defaultOpen = true,
  open: controlled,
  onOpenChange,
  openMobile: controlledMobile,
  onOpenMobileChange,
  className,
  style,
  ...props
}: React.ComponentProps<"div"> & {
  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  openMobile?: boolean;
  onOpenMobileChange?: (open: boolean) => void;
}) {
  const triggerRef = React.useRef<HTMLElement | null>(null);
  const [local, setLocal] = React.useState(defaultOpen),
    [mobile, setMobile] = React.useState(false),
    [isMobile, setIsMobile] = React.useState(false);
  const open = controlled ?? local,
    openMobile = controlledMobile ?? mobile;
  const setOpen = (v: boolean) => {
    setLocal(v);
    onOpenChange?.(v);
  };
  const setOpenMobile = (v: boolean) => {
    if (v)
      triggerRef.current =
        document.activeElement instanceof HTMLElement
          ? document.activeElement
          : null;
    setMobile(v);
    onOpenMobileChange?.(v);
  };
  React.useEffect(() => {
    const media = matchMedia("(max-width: 767px)");
    const update = () => setIsMobile(media.matches);
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);
  return (
    <Context.Provider
      value={{
        triggerRef,
        open,
        setOpen,
        openMobile,
        setOpenMobile,
        isMobile,
        toggleSidebar: () =>
          isMobile ? setOpenMobile(!openMobile) : setOpen(!open),
      }}
    >
      <div
        data-sidebar="provider"
        className={cn("flex min-h-[620px] w-full min-w-0", className)}
        style={
          {
            "--sidebar-width": "16rem",
            "--sidebar-icon-width": "4rem",
            ...style,
          } as React.CSSProperties
        }
        {...props}
      >
        {children}
      </div>
    </Context.Provider>
  );
}
export function Sidebar({
  children,
  className,
  collapsible = "icon",
  variant = "sidebar",
  label = "Navigation",
  ...props
}: React.ComponentProps<"aside"> & {
  collapsible?: "icon" | "offcanvas" | "none";
  variant?: "sidebar" | "inset" | "floating";
  label?: string;
}) {
  const { triggerRef, open, openMobile, setOpenMobile, isMobile } =
    useSidebar();
  const collapsed = !open && collapsible !== "none";
  if (isMobile)
    return (
      <Dialog.Root open={openMobile} onOpenChange={setOpenMobile}>
        <Dialog.Portal>
          <Dialog.Overlay className="jez-overlay fixed inset-0 z-40 bg-black/35" />
          <Dialog.Content
            onCloseAutoFocus={(e) => {
              e.preventDefault();
              triggerRef.current?.focus();
            }}
            aria-describedby={undefined}
            className="jez-sheet fixed inset-y-0 left-0 z-50 flex w-[min(20rem,90vw)] flex-col border-r border-border bg-background text-foreground shadow-xl"
          >
            <Dialog.Title className="sr-only">{label}</Dialog.Title>
            <Dialog.Close
              aria-label="Close sidebar"
              className="absolute right-2 top-2 z-10 rounded-md bg-background p-1.5"
            >
              <X size={16} />
            </Dialog.Close>
            <aside
              data-sidebar="sidebar"
              data-state="expanded"
              aria-label={label}
              className={cn(
                "group/sidebar flex min-h-0 flex-1 flex-col",
                className,
              )}
              {...props}
            >
              {children}
            </aside>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    );
  return (
    <aside
      data-sidebar="sidebar"
      data-state={collapsed ? "collapsed" : "expanded"}
      aria-label={label}
      className={cn(
        "group/sidebar flex shrink-0 flex-col border-r border-border bg-background text-foreground transition-[width] duration-200 motion-reduce:transition-none",
        collapsed
          ? collapsible === "offcanvas"
            ? "hidden"
            : "w-[var(--sidebar-icon-width)]"
          : "w-[var(--sidebar-width)]",
        variant === "floating" && "m-2 rounded-xl border shadow-sm",
        variant === "inset" && "border-0 bg-transparent",
        className,
      )}
      {...props}
    >
      {children}
    </aside>
  );
}
export function SidebarHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-sidebar="header"
      className={cn("shrink-0 p-3", className)}
      {...props}
    />
  );
}
export function SidebarContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-sidebar="content"
      className={cn(
        "min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2",
        className,
      )}
      {...props}
    />
  );
}
export function SidebarFooter({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-sidebar="footer"
      className={cn("mt-auto shrink-0 border-t border-border p-3", className)}
      {...props}
    />
  );
}
export function SidebarGroup({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-sidebar="group"
      className={cn("relative py-3", className)}
      {...props}
    />
  );
}
export function SidebarGroupLabel({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "mb-2 px-3 text-[11px] font-medium text-muted-foreground group-data-[state=collapsed]/sidebar:sr-only",
        className,
      )}
      {...props}
    />
  );
}
export function SidebarGroupContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return <div className={cn("w-full", className)} {...props} />;
}
export function SidebarMenu({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  return (
    <ul
      data-sidebar="menu"
      className={cn("m-0 grid list-none gap-1 p-0", className)}
      {...props}
    />
  );
}
export function SidebarMenuItem({
  className,
  ...props
}: React.ComponentProps<"li">) {
  return (
    <li
      data-sidebar="menu-item"
      className={cn("relative min-w-0", className)}
      {...props}
    />
  );
}
export function SidebarMenuButton({
  asChild = false,
  isActive = false,
  className,
  onClick,
  children,
  ...props
}: React.ComponentProps<"button"> & { asChild?: boolean; isActive?: boolean }) {
  const { setOpenMobile } = useSidebar();
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      type={asChild ? undefined : "button"}
      data-active={isActive}
      aria-current={isActive ? "page" : undefined}
      className={cn(
        "flex min-h-9 w-full min-w-0 items-center gap-3 rounded-lg px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-primary/10 data-[active=true]:font-medium data-[active=true]:text-primary [&_svg]:size-4 [&_svg]:shrink-0 group-data-[state=collapsed]/sidebar:justify-center group-data-[state=collapsed]/sidebar:px-2 group-data-[state=collapsed]/sidebar:[&>span]:sr-only",
        className,
      )}
      onClick={(e) => {
        onClick?.(e);
        if (!e.defaultPrevented) setOpenMobile(false);
      }}
      {...props}
    >
      {children}
    </Comp>
  );
}
export function SidebarMenuSub({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  return (
    <ul
      className={cn(
        "my-1 ml-5 grid list-none gap-1 border-l border-border pl-3 group-data-[state=collapsed]/sidebar:hidden",
        className,
      )}
      {...props}
    />
  );
}
export function SidebarMenuSubItem(props: React.ComponentProps<"li">) {
  return <SidebarMenuItem {...props} />;
}
export function SidebarMenuSubButton(
  props: React.ComponentProps<typeof SidebarMenuButton>,
) {
  return <SidebarMenuButton {...props} />;
}
export function SidebarMenuBadge({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      className={cn(
        "pointer-events-none absolute right-3 top-2.5 text-xs tabular-nums text-muted-foreground group-data-[state=collapsed]/sidebar:hidden",
        className,
      )}
      {...props}
    />
  );
}
export function SidebarInset({
  className,
  ...props
}: React.ComponentProps<"main">) {
  return (
    <main
      className={cn("min-w-0 flex-1 bg-background", className)}
      {...props}
    />
  );
}
export function SidebarTrigger({
  className,
  onClick,
  children,
  ...props
}: React.ComponentProps<"button">) {
  const { toggleSidebar } = useSidebar();
  return (
    <button
      type="button"
      aria-label="Toggle sidebar"
      className={cn(
        "grid size-9 place-items-center rounded-md hover:bg-muted focus-visible:outline-2 focus-visible:outline-primary",
        className,
      )}
      onClick={(e) => {
        onClick?.(e);
        if (!e.defaultPrevented) toggleSidebar();
      }}
      {...props}
    >
      {children ?? <PanelLeft size={17} />}
    </button>
  );
}

Dependencies

lucide-react@0.577.0 · radix-ui@1.6.7 · clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →