- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
A new workspace view
Organize your work in one place.
A new workspace view
Organize your work in one place.
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>
);
} On this page