Sidebar
The floating dashboard sidebar with team menu, nav, announcement, and user menu.
Rail
Team menu, nav, announcement, and user menu.
function SidebarDemo() {
return (
<div className="flex h-[560px] w-full justify-start overflow-hidden">
<DashboardSidebar fluid />
</div>
);
}function SidebarDemo() {
return (
<div className="flex h-[560px] w-full justify-start overflow-hidden">
<DashboardSidebar fluid />
</div>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/sidebar.jsonnpx shadcn@latest add https://boardcn.dev/r/sidebar.jsonnpm packages
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The 3 files the CLI copies into your project.
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
RiAsterisk,
RiCalendarLine,
RiCloseLine,
RiCustomerServiceLine,
RiFolder6Line,
RiHomeLine,
RiInbox2Line,
RiMegaphoneLine,
RiSearchLine,
RiSettings4Line,
RiSideBarFill,
RiUserSmileLine,
} from "@remixicon/react";
import { SettingsModal } from "@/components/blocks/settings/settings-modal";
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";
import { Badge } from "@/components/base/badges/badge";
import { CloseButton } from "@/components/base/buttons/close-button";
import { Kbd } from "@/components/base/kbd/kbd";
import { cx } from "@/utils/cx";
import { DashboardTeamMenu } from "./dashboard-team-menu";
import { DashboardUserMenu } from "./dashboard-user-menu";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/**
* Collapsible text/badge slot: blurs + fades + shrinks away when the rail
* closes, and blurs back in on expand. The icons/rows themselves stay pinned in
* place — only these label/badge slots animate — so nothing jumps to center.
*/
function Collapsible({ collapsed, children, className }: { collapsed: boolean; children: ReactNode; className?: string }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
className,
)}
>
{children}
</span>
);
}
function NavItem({
icon: Icon,
label,
badge,
isSelected = false,
collapsed = false,
href = "#",
onClick,
}: {
icon: IconComponent;
label: string;
badge?: ReactNode;
isSelected?: boolean;
collapsed?: boolean;
href?: string;
/** Action rows (e.g. Settings → modal) intercept the navigation. */
onClick?: () => void;
}) {
return (
<a
href={href}
onClick={
onClick
? (event) => {
event.preventDefault();
onClick();
}
: undefined
}
aria-current={isSelected ? "page" : undefined}
aria-label={label}
title={collapsed ? label : undefined}
className={cx(
"flex items-center justify-between overflow-hidden rounded-2lg p-2",
"transition-[width,background-color] duration-300 ease-in-out",
collapsed ? "w-9" : "w-full",
isSelected
? "bg-linear-to-b from-accent-500 to-accent-600 shadow-nav-selected"
: "hover:bg-background-secondary-hover",
)}
>
<span className="flex min-w-0 items-center gap-2">
<Icon
className={cx("size-5 shrink-0", isSelected ? "text-white" : "text-foreground-icon-secondary")}
aria-hidden
/>
<Collapsible collapsed={collapsed}>
<span
className={cx(
"text-body-medium whitespace-nowrap",
isSelected ? "text-white" : "text-text-secondary",
)}
>
{label}
</span>
</Collapsible>
</span>
{badge && <Collapsible collapsed={collapsed}>{badge}</Collapsible>}
</a>
);
}
export type DashboardNavKey =
| "home"
| "marketing"
| "calendar"
| "projects"
| "inbox"
| "medical"
| "profile";
export function DashboardSidebar({
mobile = false,
onClose,
fluid = false,
showThemeToggle = true,
selected = "home",
flat = false,
className,
}: {
/** Rendered inside the mobile drawer: always expanded, close button instead of collapse. */
mobile?: boolean;
onClose?: () => void;
/** Expanded width fills its container below `lg` instead of the fixed
* 260px (e.g. the landing page, where the sidebar isn't in a drawer).
* Collapsed width stays the fixed 60px rail at every breakpoint — the
* whole point of collapsing is to shrink, so it must never get
* overridden back to full width. */
fluid?: boolean;
/** Hide the app-level theme control when the sidebar is used as marketing artwork. */
showThemeToggle?: boolean;
/** Which nav item shows the selected (filled blue) state. */
selected?: DashboardNavKey;
/** Removes the floating panel treatment for a sidebar revealed beneath mobile content. */
flat?: boolean;
className?: string;
} = {}) {
const [collapsedState, setCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [suppressUserHover, setSuppressUserHover] = useState(false);
const [searchActive, setSearchActive] = useState(false);
const [query, setQuery] = useState("");
const searchTriggerRef = useRef<HTMLButtonElement>(null);
const searchFieldRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const collapsed = mobile ? false : collapsedState;
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (label: string) => label.toLocaleLowerCase().includes(normalizedQuery);
const primaryLabels = [
"Home",
"Marketing",
"Calendar",
"Projects",
"Medical Report",
"Profile",
"Inbox",
];
const secondaryLabels = ["Support", "Settings"];
const hasAnyMatch = [...primaryLabels, ...secondaryLabels].some(matches);
const activateSearch = useCallback(() => {
if (!mobile) setCollapsed(false);
setSearchActive(true);
}, [mobile]);
const deactivateSearch = useCallback((restoreFocus: boolean) => {
setQuery("");
setSearchActive(false);
if (restoreFocus) {
window.requestAnimationFrame(() => searchTriggerRef.current?.focus());
}
}, []);
useEffect(() => {
if (!searchActive) return;
const frame = window.requestAnimationFrame(() => searchInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [searchActive]);
useEffect(() => {
if (!searchActive) return;
const onOutsideClick = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && searchFieldRef.current?.contains(target)) return;
deactivateSearch(false);
};
document.addEventListener("click", onOutsideClick);
return () => document.removeEventListener("click", onOutsideClick);
}, [deactivateSearch, searchActive]);
useEffect(() => {
const onShortcut = (event: KeyboardEvent) => {
if (event.key.toLocaleLowerCase() === "l" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
activateSearch();
}
};
window.addEventListener("keydown", onShortcut);
return () => window.removeEventListener("keydown", onShortcut);
}, [activateSearch]);
return (
<aside
className={cx(
"flex h-full shrink-0 flex-col justify-between overflow-hidden",
flat
? "bg-background-full"
: "rounded-3xl border border-border-button-white bg-background-secondary-default shadow-sidebar",
"transition-[width] duration-300 ease-in-out",
// Collapsed rail keeps the 60px spec: 1px border + 11px padding on each
// side leaves an exactly 36px column so the w-9 (36px) icon items center.
collapsed
? "w-[60px] px-[11px] py-3"
: fluid
? "w-full p-3 lg:w-[260px]"
: "w-[260px] p-3",
className,
)}
>
{/* `overflow-y: auto` forces the x axis to clip too, and this box hugs
its contents on every side — so the selected item's 1px ring, the
profile's hover pill (which outsets 6px) and focus rings all landed
outside it. Padding moves the clip edge out; the matching negative
margin borrows that space back from the rail's own padding, leaving
every child exactly where it was. */}
<div
className="-m-2 flex min-h-0 w-[calc(100%+16px)] flex-col gap-3 overflow-y-auto p-2 [scrollbar-width:none]"
>
{/* Workspace switcher / collapse control */}
<div
className={cx(
"flex w-full transition-[gap] duration-300 ease-in-out",
collapsed
? "flex-col-reverse items-start justify-center gap-2.5"
: "flex-row items-center justify-between",
)}
>
{/* The clip is here to hide the label as `max-width` animates shut,
but it also cropped the trigger's hover pill down to four corner
arcs. Same trick as the scroller: pad the clip box out by the
pill's 8px reach and pull it back with a negative margin, so the
widths below are 16px larger than the footprint they produce. */}
<div
className={cx(
"-m-2 min-w-0 overflow-hidden p-2 transition-[max-width,opacity,transform] duration-300 ease-in-out",
mobile && flat && searchActive
? "max-w-0 scale-95 opacity-0"
: "max-w-[206px] scale-100 opacity-100",
)}
>
<DashboardUserMenu
collapsed={collapsed}
suppressHover={suppressUserHover}
onHoverSuppressionEnd={() => setSuppressUserHover(false)}
avatarClassName={
flat
? "bg-background-tertiary-default dark:bg-background-secondary-default"
: undefined
}
/>
</div>
{mobile && flat ? (
<div
ref={searchFieldRef}
className={cx(
"flex h-9 items-center overflow-hidden rounded-full bg-background-tertiary-default transition-[width,box-shadow] duration-300 ease-in-out",
searchActive
? "w-full gap-2 pr-2.5 pl-2 ring-2 ring-inset ring-border-button-active"
: "w-9 gap-0 px-2",
)}
>
<button
ref={searchTriggerRef}
type="button"
aria-label="Search"
onClick={activateSearch}
className="flex size-5 shrink-0 cursor-pointer items-center justify-center text-foreground-icon-secondary"
>
<RiSearchLine className="size-5" aria-hidden />
</button>
<input
ref={searchInputRef}
type="search"
aria-label="Filter template navigation"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
deactivateSearch(true);
}
}}
placeholder="Search..."
tabIndex={searchActive ? 0 : -1}
className={cx(
"min-w-0 bg-transparent text-body-medium tracking-[-0.015em] text-text-primary outline-none placeholder:text-text-tertiary",
"transition-[width,opacity] duration-200 ease-in-out",
searchActive
? "w-full flex-1 opacity-100 delay-100"
: "pointer-events-none w-0 flex-none opacity-0 delay-0",
)}
/>
<CloseButton
size="2xs"
aria-label="Clear navigation search"
onClick={() => deactivateSearch(true)}
className={cx(
"shrink-0 bg-background-tertiary-hover transition-opacity duration-150",
searchActive ? "opacity-100 delay-150" : "pointer-events-none opacity-0 delay-0",
)}
/>
</div>
) : mobile ? (
<button
type="button"
aria-label="Close sidebar"
onClick={onClose}
className="cursor-pointer text-foreground-icon-secondary"
>
<RiCloseLine className="size-5" aria-hidden />
</button>
) : (
<button
type="button"
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!collapsed}
onClick={() => {
const isExpanding = collapsedState;
if (!isExpanding) deactivateSearch(false);
setCollapsed(!collapsedState);
setSuppressUserHover(isExpanding);
}}
className={cx(
"cursor-pointer text-foreground-icon-secondary transition-transform duration-300 ease-in-out",
collapsed && "flex w-9 items-center justify-center",
)}
>
<RiSideBarFill
className={cx("size-5 transition-transform duration-300 ease-in-out", !collapsed && "-scale-x-100")}
aria-hidden
/>
</button>
)}
</div>
<div className="flex w-full flex-col gap-3">
{/* Quick search */}
{!flat && (searchActive && !collapsed ? (
<div
ref={searchFieldRef}
className="flex w-full items-center gap-2 rounded-full bg-background-tertiary-default py-2 pr-2.5 pl-2 ring-2 ring-inset ring-border-button-active transition-[background-color,box-shadow] duration-[var(--input-transition-ms)] ease"
>
<RiSearchLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<input
ref={searchInputRef}
type="search"
aria-label="Filter template navigation"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
deactivateSearch(true);
}
}}
placeholder="Search navigation…"
className="min-w-0 flex-1 bg-transparent text-body-medium text-text-primary outline-none placeholder:text-text-tertiary"
/>
<CloseButton
size="2xs"
aria-label="Clear navigation search"
onClick={() => deactivateSearch(true)}
className="bg-background-tertiary-hover"
/>
</div>
) : (
<button
ref={searchTriggerRef}
type="button"
aria-label="Quick Search"
title={collapsed ? "Quick Search" : undefined}
onClick={activateSearch}
className={cx(
"flex cursor-pointer items-center gap-2 p-2 hover:bg-background-tertiary-hover/55",
"transition-[width,border-radius,background-color] duration-300 ease-in-out",
collapsed
? "w-9 rounded-full bg-background-tertiary-default"
: "w-full rounded-full bg-background-tertiary-default",
)}
>
<span className={cx("flex min-w-0 items-center gap-2", !collapsed && "flex-1")}>
<RiSearchLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<Collapsible collapsed={collapsed}>
<span className="text-body-medium whitespace-nowrap text-text-secondary">
Quick Search
</span>
</Collapsible>
</span>
<Collapsible collapsed={collapsed}>
<Kbd>⌘L</Kbd>
</Collapsible>
</button>
))}
{/* Primary nav. The 2px inset is for the expanded rail only: the
collapsed column is exactly as wide as a 36px item, so padding
here pushes every item 2px right and the rail's own clip shaves
that much off its selected fill and hover state. */}
<nav className={cx("flex w-full flex-col gap-1", !collapsed && "px-0.5")}>
{matches("Home") && (
<NavItem
icon={RiHomeLine}
label="Home"
href="/templates/dashboard"
isSelected={selected === "home"}
collapsed={collapsed}
badge={<Badge color={selected === "home" ? "primary" : "neutral"}>152</Badge>}
/>
)}
{matches("Marketing") && (
<NavItem
icon={RiMegaphoneLine}
label="Marketing"
href="/templates/marketing"
isSelected={selected === "marketing"}
collapsed={collapsed}
/>
)}
{matches("Calendar") && (
<NavItem
icon={RiCalendarLine}
label="Calendar"
href="/templates/calendar"
isSelected={selected === "calendar"}
collapsed={collapsed}
/>
)}
{matches("Projects") && (
<NavItem
icon={RiFolder6Line}
label="Projects"
isSelected={selected === "projects"}
collapsed={collapsed}
/>
)}
{matches("Medical Report") && (
<NavItem
icon={RiAsterisk}
label="Medical Report"
href="/templates/medical-profile"
isSelected={selected === "medical"}
collapsed={collapsed}
/>
)}
{matches("Profile") && (
<NavItem
icon={RiUserSmileLine}
label="Profile"
href="/templates/ai-profile"
isSelected={selected === "profile"}
collapsed={collapsed}
/>
)}
{matches("Inbox") && (
<NavItem
icon={RiInbox2Line}
label="Inbox"
isSelected={selected === "inbox"}
collapsed={collapsed}
badge={<Badge color={selected === "inbox" ? "primary" : "neutral"}>91</Badge>}
/>
)}
{!hasAnyMatch && !collapsed && (
<p className="px-2 py-3 text-body-regular text-text-tertiary">No results</p>
)}
</nav>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-3">
{showThemeToggle &&
(collapsed ? (
<ThemeToggle collapsed />
) : (
<ThemeToggle
appearance="sidebar-segmented"
className={flat ? "!bg-background-secondary-default" : undefined}
/>
))}
{/* Secondary nav */}
<nav className="flex w-full flex-col gap-1">
{matches("Support") && (
<NavItem icon={RiCustomerServiceLine} label="Support" collapsed={collapsed} />
)}
{matches("Settings") && (
<NavItem
icon={RiSettings4Line}
label="Settings"
collapsed={collapsed}
onClick={() => setSettingsOpen(true)}
/>
)}
</nav>
{/* Team card → opens the profile menu next to the sidebar */}
<DashboardTeamMenu
collapsed={collapsed}
className={flat ? "!bg-background-secondary-default" : undefined}
/>
</div>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
planArtSrc="/templates/settings-plan-art.png"
/>
</aside>
);
}"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
RiAsterisk,
RiCalendarLine,
RiCloseLine,
RiCustomerServiceLine,
RiFolder6Line,
RiHomeLine,
RiInbox2Line,
RiMegaphoneLine,
RiSearchLine,
RiSettings4Line,
RiSideBarFill,
RiUserSmileLine,
} from "@remixicon/react";
import { SettingsModal } from "@/components/blocks/settings/settings-modal";
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";
import { Badge } from "@/components/base/badges/badge";
import { CloseButton } from "@/components/base/buttons/close-button";
import { Kbd } from "@/components/base/kbd/kbd";
import { cx } from "@/utils/cx";
import { DashboardTeamMenu } from "./dashboard-team-menu";
import { DashboardUserMenu } from "./dashboard-user-menu";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/**
* Collapsible text/badge slot: blurs + fades + shrinks away when the rail
* closes, and blurs back in on expand. The icons/rows themselves stay pinned in
* place — only these label/badge slots animate — so nothing jumps to center.
*/
function Collapsible({ collapsed, children, className }: { collapsed: boolean; children: ReactNode; className?: string }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
className,
)}
>
{children}
</span>
);
}
function NavItem({
icon: Icon,
label,
badge,
isSelected = false,
collapsed = false,
href = "#",
onClick,
}: {
icon: IconComponent;
label: string;
badge?: ReactNode;
isSelected?: boolean;
collapsed?: boolean;
href?: string;
/** Action rows (e.g. Settings → modal) intercept the navigation. */
onClick?: () => void;
}) {
return (
<a
href={href}
onClick={
onClick
? (event) => {
event.preventDefault();
onClick();
}
: undefined
}
aria-current={isSelected ? "page" : undefined}
aria-label={label}
title={collapsed ? label : undefined}
className={cx(
"flex items-center justify-between overflow-hidden rounded-2lg p-2",
"transition-[width,background-color] duration-300 ease-in-out",
collapsed ? "w-9" : "w-full",
isSelected
? "bg-linear-to-b from-accent-500 to-accent-600 shadow-nav-selected"
: "hover:bg-background-secondary-hover",
)}
>
<span className="flex min-w-0 items-center gap-2">
<Icon
className={cx("size-5 shrink-0", isSelected ? "text-white" : "text-foreground-icon-secondary")}
aria-hidden
/>
<Collapsible collapsed={collapsed}>
<span
className={cx(
"text-body-medium whitespace-nowrap",
isSelected ? "text-white" : "text-text-secondary",
)}
>
{label}
</span>
</Collapsible>
</span>
{badge && <Collapsible collapsed={collapsed}>{badge}</Collapsible>}
</a>
);
}
export type DashboardNavKey =
| "home"
| "marketing"
| "calendar"
| "projects"
| "inbox"
| "medical"
| "profile";
export function DashboardSidebar({
mobile = false,
onClose,
fluid = false,
showThemeToggle = true,
selected = "home",
flat = false,
className,
}: {
/** Rendered inside the mobile drawer: always expanded, close button instead of collapse. */
mobile?: boolean;
onClose?: () => void;
/** Expanded width fills its container below `lg` instead of the fixed
* 260px (e.g. the landing page, where the sidebar isn't in a drawer).
* Collapsed width stays the fixed 60px rail at every breakpoint — the
* whole point of collapsing is to shrink, so it must never get
* overridden back to full width. */
fluid?: boolean;
/** Hide the app-level theme control when the sidebar is used as marketing artwork. */
showThemeToggle?: boolean;
/** Which nav item shows the selected (filled blue) state. */
selected?: DashboardNavKey;
/** Removes the floating panel treatment for a sidebar revealed beneath mobile content. */
flat?: boolean;
className?: string;
} = {}) {
const [collapsedState, setCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [suppressUserHover, setSuppressUserHover] = useState(false);
const [searchActive, setSearchActive] = useState(false);
const [query, setQuery] = useState("");
const searchTriggerRef = useRef<HTMLButtonElement>(null);
const searchFieldRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const collapsed = mobile ? false : collapsedState;
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (label: string) => label.toLocaleLowerCase().includes(normalizedQuery);
const primaryLabels = [
"Home",
"Marketing",
"Calendar",
"Projects",
"Medical Report",
"Profile",
"Inbox",
];
const secondaryLabels = ["Support", "Settings"];
const hasAnyMatch = [...primaryLabels, ...secondaryLabels].some(matches);
const activateSearch = useCallback(() => {
if (!mobile) setCollapsed(false);
setSearchActive(true);
}, [mobile]);
const deactivateSearch = useCallback((restoreFocus: boolean) => {
setQuery("");
setSearchActive(false);
if (restoreFocus) {
window.requestAnimationFrame(() => searchTriggerRef.current?.focus());
}
}, []);
useEffect(() => {
if (!searchActive) return;
const frame = window.requestAnimationFrame(() => searchInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [searchActive]);
useEffect(() => {
if (!searchActive) return;
const onOutsideClick = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && searchFieldRef.current?.contains(target)) return;
deactivateSearch(false);
};
document.addEventListener("click", onOutsideClick);
return () => document.removeEventListener("click", onOutsideClick);
}, [deactivateSearch, searchActive]);
useEffect(() => {
const onShortcut = (event: KeyboardEvent) => {
if (event.key.toLocaleLowerCase() === "l" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
activateSearch();
}
};
window.addEventListener("keydown", onShortcut);
return () => window.removeEventListener("keydown", onShortcut);
}, [activateSearch]);
return (
<aside
className={cx(
"flex h-full shrink-0 flex-col justify-between overflow-hidden",
flat
? "bg-background-full"
: "rounded-3xl border border-border-button-white bg-background-secondary-default shadow-sidebar",
"transition-[width] duration-300 ease-in-out",
// Collapsed rail keeps the 60px spec: 1px border + 11px padding on each
// side leaves an exactly 36px column so the w-9 (36px) icon items center.
collapsed
? "w-[60px] px-[11px] py-3"
: fluid
? "w-full p-3 lg:w-[260px]"
: "w-[260px] p-3",
className,
)}
>
{/* `overflow-y: auto` forces the x axis to clip too, and this box hugs
its contents on every side — so the selected item's 1px ring, the
profile's hover pill (which outsets 6px) and focus rings all landed
outside it. Padding moves the clip edge out; the matching negative
margin borrows that space back from the rail's own padding, leaving
every child exactly where it was. */}
<div
className="-m-2 flex min-h-0 w-[calc(100%+16px)] flex-col gap-3 overflow-y-auto p-2 [scrollbar-width:none]"
>
{/* Workspace switcher / collapse control */}
<div
className={cx(
"flex w-full transition-[gap] duration-300 ease-in-out",
collapsed
? "flex-col-reverse items-start justify-center gap-2.5"
: "flex-row items-center justify-between",
)}
>
{/* The clip is here to hide the label as `max-width` animates shut,
but it also cropped the trigger's hover pill down to four corner
arcs. Same trick as the scroller: pad the clip box out by the
pill's 8px reach and pull it back with a negative margin, so the
widths below are 16px larger than the footprint they produce. */}
<div
className={cx(
"-m-2 min-w-0 overflow-hidden p-2 transition-[max-width,opacity,transform] duration-300 ease-in-out",
mobile && flat && searchActive
? "max-w-0 scale-95 opacity-0"
: "max-w-[206px] scale-100 opacity-100",
)}
>
<DashboardUserMenu
collapsed={collapsed}
suppressHover={suppressUserHover}
onHoverSuppressionEnd={() => setSuppressUserHover(false)}
avatarClassName={
flat
? "bg-background-tertiary-default dark:bg-background-secondary-default"
: undefined
}
/>
</div>
{mobile && flat ? (
<div
ref={searchFieldRef}
className={cx(
"flex h-9 items-center overflow-hidden rounded-full bg-background-tertiary-default transition-[width,box-shadow] duration-300 ease-in-out",
searchActive
? "w-full gap-2 pr-2.5 pl-2 ring-2 ring-inset ring-border-button-active"
: "w-9 gap-0 px-2",
)}
>
<button
ref={searchTriggerRef}
type="button"
aria-label="Search"
onClick={activateSearch}
className="flex size-5 shrink-0 cursor-pointer items-center justify-center text-foreground-icon-secondary"
>
<RiSearchLine className="size-5" aria-hidden />
</button>
<input
ref={searchInputRef}
type="search"
aria-label="Filter template navigation"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
deactivateSearch(true);
}
}}
placeholder="Search..."
tabIndex={searchActive ? 0 : -1}
className={cx(
"min-w-0 bg-transparent text-body-medium tracking-[-0.015em] text-text-primary outline-none placeholder:text-text-tertiary",
"transition-[width,opacity] duration-200 ease-in-out",
searchActive
? "w-full flex-1 opacity-100 delay-100"
: "pointer-events-none w-0 flex-none opacity-0 delay-0",
)}
/>
<CloseButton
size="2xs"
aria-label="Clear navigation search"
onClick={() => deactivateSearch(true)}
className={cx(
"shrink-0 bg-background-tertiary-hover transition-opacity duration-150",
searchActive ? "opacity-100 delay-150" : "pointer-events-none opacity-0 delay-0",
)}
/>
</div>
) : mobile ? (
<button
type="button"
aria-label="Close sidebar"
onClick={onClose}
className="cursor-pointer text-foreground-icon-secondary"
>
<RiCloseLine className="size-5" aria-hidden />
</button>
) : (
<button
type="button"
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!collapsed}
onClick={() => {
const isExpanding = collapsedState;
if (!isExpanding) deactivateSearch(false);
setCollapsed(!collapsedState);
setSuppressUserHover(isExpanding);
}}
className={cx(
"cursor-pointer text-foreground-icon-secondary transition-transform duration-300 ease-in-out",
collapsed && "flex w-9 items-center justify-center",
)}
>
<RiSideBarFill
className={cx("size-5 transition-transform duration-300 ease-in-out", !collapsed && "-scale-x-100")}
aria-hidden
/>
</button>
)}
</div>
<div className="flex w-full flex-col gap-3">
{/* Quick search */}
{!flat && (searchActive && !collapsed ? (
<div
ref={searchFieldRef}
className="flex w-full items-center gap-2 rounded-full bg-background-tertiary-default py-2 pr-2.5 pl-2 ring-2 ring-inset ring-border-button-active transition-[background-color,box-shadow] duration-[var(--input-transition-ms)] ease"
>
<RiSearchLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<input
ref={searchInputRef}
type="search"
aria-label="Filter template navigation"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
deactivateSearch(true);
}
}}
placeholder="Search navigation…"
className="min-w-0 flex-1 bg-transparent text-body-medium text-text-primary outline-none placeholder:text-text-tertiary"
/>
<CloseButton
size="2xs"
aria-label="Clear navigation search"
onClick={() => deactivateSearch(true)}
className="bg-background-tertiary-hover"
/>
</div>
) : (
<button
ref={searchTriggerRef}
type="button"
aria-label="Quick Search"
title={collapsed ? "Quick Search" : undefined}
onClick={activateSearch}
className={cx(
"flex cursor-pointer items-center gap-2 p-2 hover:bg-background-tertiary-hover/55",
"transition-[width,border-radius,background-color] duration-300 ease-in-out",
collapsed
? "w-9 rounded-full bg-background-tertiary-default"
: "w-full rounded-full bg-background-tertiary-default",
)}
>
<span className={cx("flex min-w-0 items-center gap-2", !collapsed && "flex-1")}>
<RiSearchLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<Collapsible collapsed={collapsed}>
<span className="text-body-medium whitespace-nowrap text-text-secondary">
Quick Search
</span>
</Collapsible>
</span>
<Collapsible collapsed={collapsed}>
<Kbd>⌘L</Kbd>
</Collapsible>
</button>
))}
{/* Primary nav. The 2px inset is for the expanded rail only: the
collapsed column is exactly as wide as a 36px item, so padding
here pushes every item 2px right and the rail's own clip shaves
that much off its selected fill and hover state. */}
<nav className={cx("flex w-full flex-col gap-1", !collapsed && "px-0.5")}>
{matches("Home") && (
<NavItem
icon={RiHomeLine}
label="Home"
href="/templates/dashboard"
isSelected={selected === "home"}
collapsed={collapsed}
badge={<Badge color={selected === "home" ? "primary" : "neutral"}>152</Badge>}
/>
)}
{matches("Marketing") && (
<NavItem
icon={RiMegaphoneLine}
label="Marketing"
href="/templates/marketing"
isSelected={selected === "marketing"}
collapsed={collapsed}
/>
)}
{matches("Calendar") && (
<NavItem
icon={RiCalendarLine}
label="Calendar"
href="/templates/calendar"
isSelected={selected === "calendar"}
collapsed={collapsed}
/>
)}
{matches("Projects") && (
<NavItem
icon={RiFolder6Line}
label="Projects"
isSelected={selected === "projects"}
collapsed={collapsed}
/>
)}
{matches("Medical Report") && (
<NavItem
icon={RiAsterisk}
label="Medical Report"
href="/templates/medical-profile"
isSelected={selected === "medical"}
collapsed={collapsed}
/>
)}
{matches("Profile") && (
<NavItem
icon={RiUserSmileLine}
label="Profile"
href="/templates/ai-profile"
isSelected={selected === "profile"}
collapsed={collapsed}
/>
)}
{matches("Inbox") && (
<NavItem
icon={RiInbox2Line}
label="Inbox"
isSelected={selected === "inbox"}
collapsed={collapsed}
badge={<Badge color={selected === "inbox" ? "primary" : "neutral"}>91</Badge>}
/>
)}
{!hasAnyMatch && !collapsed && (
<p className="px-2 py-3 text-body-regular text-text-tertiary">No results</p>
)}
</nav>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-3">
{showThemeToggle &&
(collapsed ? (
<ThemeToggle collapsed />
) : (
<ThemeToggle
appearance="sidebar-segmented"
className={flat ? "!bg-background-secondary-default" : undefined}
/>
))}
{/* Secondary nav */}
<nav className="flex w-full flex-col gap-1">
{matches("Support") && (
<NavItem icon={RiCustomerServiceLine} label="Support" collapsed={collapsed} />
)}
{matches("Settings") && (
<NavItem
icon={RiSettings4Line}
label="Settings"
collapsed={collapsed}
onClick={() => setSettingsOpen(true)}
/>
)}
</nav>
{/* Team card → opens the profile menu next to the sidebar */}
<DashboardTeamMenu
collapsed={collapsed}
className={flat ? "!bg-background-secondary-default" : undefined}
/>
</div>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
planArtSrc="/templates/settings-plan-art.png"
/>
</aside>
);
}"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import {
RiBankCardLine,
RiBankLine,
RiBox3Line,
RiFolder6Line,
RiGroupLine,
RiLogoutBoxRLine,
RiMessage2Line,
RiNotification3Line,
RiSchoolLine,
RiShieldUserLine,
} from "@remixicon/react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badge";
import { ChevronDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
type MenuRow = {
icon: IconComponent;
label: string;
badge?: string;
isSelected?: boolean;
};
type MenuGroup = {
id: string;
label?: string;
items: MenuRow[];
};
const GROUPS: MenuGroup[] = [
{
id: "workspace",
items: [
{ icon: RiBankLine, label: "View team profile" },
{ icon: RiFolder6Line, label: "Folders" },
{ icon: RiMessage2Line, label: "Messages", badge: "94" },
{ icon: RiGroupLine, label: "People" },
],
},
{
id: "company",
label: "Company",
items: [
{ icon: RiBankCardLine, label: "Billing" },
{ icon: RiSchoolLine, label: "Company Details" },
{ icon: RiBox3Line, label: "Integrations" },
],
},
{
id: "personal",
label: "Personal",
items: [
{ icon: RiNotification3Line, label: "Notifications" },
{ icon: RiShieldUserLine, label: "Account Details" },
{ icon: RiLogoutBoxRLine, label: "Sign out" },
],
},
];
/** Label/chevron slot on the trigger: blurs + fades away as the rail collapses. */
function Collapsible({ collapsed, children }: { collapsed: boolean; children: ReactNode }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
)}
>
{children}
</span>
);
}
function TeamMenuItem({ icon: Icon, label, badge, isSelected, onSelect }: MenuRow & { onSelect: () => void }) {
return (
<a
href="#"
aria-current={isSelected ? "page" : undefined}
onClick={onSelect}
className={cx(
"flex w-full items-center gap-2.5 rounded-2lg p-2 outline-none transition-colors",
isSelected
? "bg-background-primary-hover"
: "hover:bg-background-primary-hover focus-visible:bg-background-primary-hover",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium text-text-primary">{label}</span>
</span>
{badge && (
<Badge
color="neutral"
className="bg-team-menu-count-background text-team-menu-count-foreground"
>
{badge}
</Badge>
)}
</a>
);
}
export function DashboardTeamMenu({
collapsed = false,
className,
}: {
collapsed?: boolean;
className?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
// "right" placement assumes room to the sidebar's right (true in-flow on
// desktop) — on mobile the sidebar can span the full viewport, so the
// 265px panel would render off-screen. Below sm, drop into a plain
// dropdown under the trigger instead.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
setIsMobile(mq.matches);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return (
<AriaDialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<AriaButton
aria-label="BoardCN team"
className={cx(
"flex cursor-pointer items-center overflow-hidden outline-none",
"border-2 border-transparent hover:border-border-button-hover",
"transition-[width,background-color,border-color,padding] duration-300 ease-in-out",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring focus-visible:ring-offset-2",
collapsed
? "size-9 justify-start rounded-full bg-transparent p-0"
: "w-full justify-between rounded-xl bg-background-tertiary-default py-2 pr-4 pl-2.5",
className,
)}
>
<span className="flex min-w-0 items-center gap-2">
<Avatar size="md" color="blue" initials="B" />
<Collapsible collapsed={collapsed}>
<span className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium whitespace-nowrap text-text-primary">BoardCN team</span>
<span className="text-body-regular whitespace-nowrap text-text-secondary">hello@boardcn.dev</span>
</span>
</Collapsible>
</span>
<Collapsible collapsed={collapsed}>
<span className="flex size-4 shrink-0 items-center justify-center rounded-[3px] bg-background-tertiary-hover">
<ChevronDownSmall
className={cx("size-4 text-text-secondary transition-transform duration-200 ease", isOpen && "rotate-180")}
/>
</span>
</Collapsible>
</AriaButton>
<AriaPopover
placement={isMobile ? "bottom start" : "right bottom"}
offset={8}
className={cx(
"w-[265px] max-w-[calc(100vw-32px)] origin-bottom-left overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
)}
>
<AriaDialog aria-label="BoardCN team menu" className="flex flex-col gap-[7px] outline-none">
{/* Header */}
<div className="flex w-full items-center gap-2 px-2 pt-1">
<Avatar size="md" color="blue" initials="B" />
<div className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium whitespace-nowrap text-text-primary">BoardCN team</span>
<span className="text-body-regular whitespace-nowrap text-text-secondary">hello@boardcn.dev</span>
</div>
</div>
{/* Grouped sidebar-style rows */}
<div className="flex w-full flex-col">
{GROUPS.map((group, index) => (
<Group key={group.id} group={group} showDivider={index > 0} onSelect={() => setIsOpen(false)} />
))}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-between px-2 pt-1 pb-2">
<span className="text-body-2-medium whitespace-nowrap text-text-tertiary">BoardCN</span>
<span className="inline-flex items-center justify-center rounded-sm bg-background-tertiary-default px-1 py-px text-body-2-medium whitespace-nowrap text-text-tertiary">
v1.0.1
</span>
</div>
</AriaDialog>
</AriaPopover>
</AriaDialogTrigger>
);
}
function Group({ group, showDivider, onSelect }: { group: MenuGroup; showDivider: boolean; onSelect: () => void }) {
return (
<>
{showDivider && <div className="-mx-2.5 my-2.5 h-px bg-border-button-default" />}
<div className={cx("flex w-full flex-col gap-1", group.label && "gap-1.5 pt-1")}>
{group.label && (
<span className="px-2 text-body-medium text-text-secondary">{group.label}</span>
)}
<div className="flex w-full flex-col gap-1">
{group.items.map((item) => (
<TeamMenuItem key={item.label} {...item} onSelect={onSelect} />
))}
</div>
</div>
</>
);
}"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import {
RiBankCardLine,
RiBankLine,
RiBox3Line,
RiFolder6Line,
RiGroupLine,
RiLogoutBoxRLine,
RiMessage2Line,
RiNotification3Line,
RiSchoolLine,
RiShieldUserLine,
} from "@remixicon/react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badge";
import { ChevronDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
type MenuRow = {
icon: IconComponent;
label: string;
badge?: string;
isSelected?: boolean;
};
type MenuGroup = {
id: string;
label?: string;
items: MenuRow[];
};
const GROUPS: MenuGroup[] = [
{
id: "workspace",
items: [
{ icon: RiBankLine, label: "View team profile" },
{ icon: RiFolder6Line, label: "Folders" },
{ icon: RiMessage2Line, label: "Messages", badge: "94" },
{ icon: RiGroupLine, label: "People" },
],
},
{
id: "company",
label: "Company",
items: [
{ icon: RiBankCardLine, label: "Billing" },
{ icon: RiSchoolLine, label: "Company Details" },
{ icon: RiBox3Line, label: "Integrations" },
],
},
{
id: "personal",
label: "Personal",
items: [
{ icon: RiNotification3Line, label: "Notifications" },
{ icon: RiShieldUserLine, label: "Account Details" },
{ icon: RiLogoutBoxRLine, label: "Sign out" },
],
},
];
/** Label/chevron slot on the trigger: blurs + fades away as the rail collapses. */
function Collapsible({ collapsed, children }: { collapsed: boolean; children: ReactNode }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
)}
>
{children}
</span>
);
}
function TeamMenuItem({ icon: Icon, label, badge, isSelected, onSelect }: MenuRow & { onSelect: () => void }) {
return (
<a
href="#"
aria-current={isSelected ? "page" : undefined}
onClick={onSelect}
className={cx(
"flex w-full items-center gap-2.5 rounded-2lg p-2 outline-none transition-colors",
isSelected
? "bg-background-primary-hover"
: "hover:bg-background-primary-hover focus-visible:bg-background-primary-hover",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium text-text-primary">{label}</span>
</span>
{badge && (
<Badge
color="neutral"
className="bg-team-menu-count-background text-team-menu-count-foreground"
>
{badge}
</Badge>
)}
</a>
);
}
export function DashboardTeamMenu({
collapsed = false,
className,
}: {
collapsed?: boolean;
className?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
// "right" placement assumes room to the sidebar's right (true in-flow on
// desktop) — on mobile the sidebar can span the full viewport, so the
// 265px panel would render off-screen. Below sm, drop into a plain
// dropdown under the trigger instead.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
setIsMobile(mq.matches);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return (
<AriaDialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<AriaButton
aria-label="BoardCN team"
className={cx(
"flex cursor-pointer items-center overflow-hidden outline-none",
"border-2 border-transparent hover:border-border-button-hover",
"transition-[width,background-color,border-color,padding] duration-300 ease-in-out",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring focus-visible:ring-offset-2",
collapsed
? "size-9 justify-start rounded-full bg-transparent p-0"
: "w-full justify-between rounded-xl bg-background-tertiary-default py-2 pr-4 pl-2.5",
className,
)}
>
<span className="flex min-w-0 items-center gap-2">
<Avatar size="md" color="blue" initials="B" />
<Collapsible collapsed={collapsed}>
<span className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium whitespace-nowrap text-text-primary">BoardCN team</span>
<span className="text-body-regular whitespace-nowrap text-text-secondary">hello@boardcn.dev</span>
</span>
</Collapsible>
</span>
<Collapsible collapsed={collapsed}>
<span className="flex size-4 shrink-0 items-center justify-center rounded-[3px] bg-background-tertiary-hover">
<ChevronDownSmall
className={cx("size-4 text-text-secondary transition-transform duration-200 ease", isOpen && "rotate-180")}
/>
</span>
</Collapsible>
</AriaButton>
<AriaPopover
placement={isMobile ? "bottom start" : "right bottom"}
offset={8}
className={cx(
"w-[265px] max-w-[calc(100vw-32px)] origin-bottom-left overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
)}
>
<AriaDialog aria-label="BoardCN team menu" className="flex flex-col gap-[7px] outline-none">
{/* Header */}
<div className="flex w-full items-center gap-2 px-2 pt-1">
<Avatar size="md" color="blue" initials="B" />
<div className="flex min-w-0 flex-col items-start justify-center">
<span className="text-body-medium whitespace-nowrap text-text-primary">BoardCN team</span>
<span className="text-body-regular whitespace-nowrap text-text-secondary">hello@boardcn.dev</span>
</div>
</div>
{/* Grouped sidebar-style rows */}
<div className="flex w-full flex-col">
{GROUPS.map((group, index) => (
<Group key={group.id} group={group} showDivider={index > 0} onSelect={() => setIsOpen(false)} />
))}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-between px-2 pt-1 pb-2">
<span className="text-body-2-medium whitespace-nowrap text-text-tertiary">BoardCN</span>
<span className="inline-flex items-center justify-center rounded-sm bg-background-tertiary-default px-1 py-px text-body-2-medium whitespace-nowrap text-text-tertiary">
v1.0.1
</span>
</div>
</AriaDialog>
</AriaPopover>
</AriaDialogTrigger>
);
}
function Group({ group, showDivider, onSelect }: { group: MenuGroup; showDivider: boolean; onSelect: () => void }) {
return (
<>
{showDivider && <div className="-mx-2.5 my-2.5 h-px bg-border-button-default" />}
<div className={cx("flex w-full flex-col gap-1", group.label && "gap-1.5 pt-1")}>
{group.label && (
<span className="px-2 text-body-medium text-text-secondary">{group.label}</span>
)}
<div className="flex w-full flex-col gap-1">
{group.items.map((item) => (
<TeamMenuItem key={item.label} {...item} onSelect={onSelect} />
))}
</div>
</div>
</>
);
}"use client";
import { useEffect, useState, type ReactNode } from "react";
import { RiAddFill, RiEqualizer3Line } from "@remixicon/react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ChevronUpDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
type AvatarColor = "neutral" | "lime" | "pink";
type UserRow = {
initials: string;
color: AvatarColor;
name: string;
isSelected?: boolean;
};
const USERS: UserRow[] = [
{ initials: "M", color: "neutral", name: "Mertcan Esmergul" },
{ initials: "S", color: "lime", name: "Steven Raule" },
{ initials: "L", color: "pink", name: "Lauren Proso" },
];
/** Label slot on the trigger: blurs + fades away as the rail collapses. */
function Collapsible({ collapsed, children }: { collapsed: boolean; children: ReactNode }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
)}
>
{children}
</span>
);
}
function UserMenuItem({ initials, color, name, isSelected, onSelect }: UserRow & { onSelect: () => void }) {
return (
<a
href="#"
aria-current={isSelected ? "page" : undefined}
onClick={onSelect}
className={cx(
"flex w-full items-center gap-2 rounded-2lg px-2 py-1.5 outline-none transition-colors",
isSelected
? "bg-background-primary-hover"
: "hover:bg-background-primary-hover focus-visible:bg-background-primary-hover",
)}
>
<Avatar size="xs" color={color} initials={initials} />
<span className="truncate text-body-medium text-text-primary">{name}</span>
</a>
);
}
/** The dropdown's contents — users-with-access list + Add user/Manage
* actions — split out so other triggers (e.g. the calendar template's
* inbox icon) can open the same panel without duplicating it. */
export function AccountMenuContent({ onSelect }: { onSelect: () => void }) {
return (
<>
{/* Users with access */}
<div className="flex w-full flex-col gap-1.5 pt-[5px]">
<span className="px-2 text-body-medium text-text-secondary">Users with access</span>
<div className="flex w-full flex-col gap-1">
{USERS.map((user) => (
<UserMenuItem key={user.name} {...user} onSelect={onSelect} />
))}
</div>
</div>
{/* Divider centered in the 28px gap between the list and the actions */}
<div className="-mx-2.5 my-3.5 h-px bg-border-button-default" />
{/* Actions — pb-2 makes the space below the buttons match their
left/right inset (panel p-2.5 + row px-2 = 18px on every side). */}
<div className="flex w-full items-center gap-3 px-2 pb-2">
<Button variant="secondary" size="small" leadingIcon={RiAddFill} className="flex-1" onClick={onSelect}>
Add user
</Button>
<Button
variant="secondary"
size="small"
leadingIcon={RiEqualizer3Line}
className="flex-1"
onClick={onSelect}
>
Manage
</Button>
</div>
</>
);
}
export function DashboardUserMenu({
collapsed = false,
suppressHover = false,
onHoverSuppressionEnd,
avatarClassName,
}: {
collapsed?: boolean;
/** Prevents expansion from creating a hover state under a stationary pointer. */
suppressHover?: boolean;
/** Re-arms hover after the pointer fully leaves the trigger. */
onHoverSuppressionEnd?: () => void;
avatarClassName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
// "right" placement assumes room to the sidebar's right (true in-flow on
// desktop) — on mobile the sidebar can span the full viewport, so the
// 265px panel would render off-screen. Below sm, drop into a plain
// dropdown under the trigger instead.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
setIsMobile(mq.matches);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return (
<AriaDialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<AriaButton
aria-label="Mertcan Esmergul"
onPointerLeave={() => {
if (suppressHover) onHoverSuppressionEnd?.();
}}
className={cx(
"relative flex min-w-0 cursor-pointer items-center gap-2 rounded-full outline-none",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring focus-visible:ring-offset-2",
// outline drawn via a pseudo-element so it never shifts the layout.
"before:pointer-events-none before:absolute before:-inset-x-1.5 before:-inset-y-[5px] before:rounded-full before:border-2 before:border-transparent before:transition-colors before:duration-150",
!suppressHover && "hover:before:border-border-sidebar-profile-hover",
// Collapsed, the trigger takes the rail's own 36px column and centres
// the 32px avatar in it, instead of sizing to the avatar plus a gap
// held open for a label that's shrunk to nothing. That gap made the
// button 42px wide in a 36px rail, which pushed its hover pill
// off-centre and into the rail's clip.
//
// The pill's insets go square too: 36×32 plus the expanded 6/5 reach
// is a 48×42 stadium, not the circle the avatar wants. 3/5 lands it
// on 42×42.
collapsed && "w-9 justify-center gap-0 before:-inset-x-[3px]",
)}
>
<Avatar size="md" color="neutral" initials="M" className={avatarClassName} />
<Collapsible collapsed={collapsed}>
<span className="flex items-center gap-0.5">
<span className="text-body-medium whitespace-nowrap text-text-primary">Mertcan Esmergul</span>
<ChevronUpDownSmall className="size-4 shrink-0 text-foreground-icon-tertiary" />
</span>
</Collapsible>
</AriaButton>
<AriaPopover
placement={isMobile ? "bottom start" : "right top"}
offset={8}
className={cx(
"w-[265px] max-w-[calc(100vw-32px)] origin-top-left overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
)}
>
<AriaDialog aria-label="Account menu" className="flex flex-col outline-none">
<AccountMenuContent onSelect={() => setIsOpen(false)} />
</AriaDialog>
</AriaPopover>
</AriaDialogTrigger>
);
}"use client";
import { useEffect, useState, type ReactNode } from "react";
import { RiAddFill, RiEqualizer3Line } from "@remixicon/react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Popover as AriaPopover,
} from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ChevronUpDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
type AvatarColor = "neutral" | "lime" | "pink";
type UserRow = {
initials: string;
color: AvatarColor;
name: string;
isSelected?: boolean;
};
const USERS: UserRow[] = [
{ initials: "M", color: "neutral", name: "Mertcan Esmergul" },
{ initials: "S", color: "lime", name: "Steven Raule" },
{ initials: "L", color: "pink", name: "Lauren Proso" },
];
/** Label slot on the trigger: blurs + fades away as the rail collapses. */
function Collapsible({ collapsed, children }: { collapsed: boolean; children: ReactNode }) {
return (
<span
className={cx(
"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,filter] duration-300 ease-in-out",
collapsed ? "max-w-0 opacity-0 blur-[3px]" : "max-w-40 opacity-100 blur-0",
)}
>
{children}
</span>
);
}
function UserMenuItem({ initials, color, name, isSelected, onSelect }: UserRow & { onSelect: () => void }) {
return (
<a
href="#"
aria-current={isSelected ? "page" : undefined}
onClick={onSelect}
className={cx(
"flex w-full items-center gap-2 rounded-2lg px-2 py-1.5 outline-none transition-colors",
isSelected
? "bg-background-primary-hover"
: "hover:bg-background-primary-hover focus-visible:bg-background-primary-hover",
)}
>
<Avatar size="xs" color={color} initials={initials} />
<span className="truncate text-body-medium text-text-primary">{name}</span>
</a>
);
}
/** The dropdown's contents — users-with-access list + Add user/Manage
* actions — split out so other triggers (e.g. the calendar template's
* inbox icon) can open the same panel without duplicating it. */
export function AccountMenuContent({ onSelect }: { onSelect: () => void }) {
return (
<>
{/* Users with access */}
<div className="flex w-full flex-col gap-1.5 pt-[5px]">
<span className="px-2 text-body-medium text-text-secondary">Users with access</span>
<div className="flex w-full flex-col gap-1">
{USERS.map((user) => (
<UserMenuItem key={user.name} {...user} onSelect={onSelect} />
))}
</div>
</div>
{/* Divider centered in the 28px gap between the list and the actions */}
<div className="-mx-2.5 my-3.5 h-px bg-border-button-default" />
{/* Actions — pb-2 makes the space below the buttons match their
left/right inset (panel p-2.5 + row px-2 = 18px on every side). */}
<div className="flex w-full items-center gap-3 px-2 pb-2">
<Button variant="secondary" size="small" leadingIcon={RiAddFill} className="flex-1" onClick={onSelect}>
Add user
</Button>
<Button
variant="secondary"
size="small"
leadingIcon={RiEqualizer3Line}
className="flex-1"
onClick={onSelect}
>
Manage
</Button>
</div>
</>
);
}
export function DashboardUserMenu({
collapsed = false,
suppressHover = false,
onHoverSuppressionEnd,
avatarClassName,
}: {
collapsed?: boolean;
/** Prevents expansion from creating a hover state under a stationary pointer. */
suppressHover?: boolean;
/** Re-arms hover after the pointer fully leaves the trigger. */
onHoverSuppressionEnd?: () => void;
avatarClassName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
// "right" placement assumes room to the sidebar's right (true in-flow on
// desktop) — on mobile the sidebar can span the full viewport, so the
// 265px panel would render off-screen. Below sm, drop into a plain
// dropdown under the trigger instead.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
setIsMobile(mq.matches);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return (
<AriaDialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<AriaButton
aria-label="Mertcan Esmergul"
onPointerLeave={() => {
if (suppressHover) onHoverSuppressionEnd?.();
}}
className={cx(
"relative flex min-w-0 cursor-pointer items-center gap-2 rounded-full outline-none",
"focus-visible:ring-2 focus-visible:ring-border-focus-ring focus-visible:ring-offset-2",
// outline drawn via a pseudo-element so it never shifts the layout.
"before:pointer-events-none before:absolute before:-inset-x-1.5 before:-inset-y-[5px] before:rounded-full before:border-2 before:border-transparent before:transition-colors before:duration-150",
!suppressHover && "hover:before:border-border-sidebar-profile-hover",
// Collapsed, the trigger takes the rail's own 36px column and centres
// the 32px avatar in it, instead of sizing to the avatar plus a gap
// held open for a label that's shrunk to nothing. That gap made the
// button 42px wide in a 36px rail, which pushed its hover pill
// off-centre and into the rail's clip.
//
// The pill's insets go square too: 36×32 plus the expanded 6/5 reach
// is a 48×42 stadium, not the circle the avatar wants. 3/5 lands it
// on 42×42.
collapsed && "w-9 justify-center gap-0 before:-inset-x-[3px]",
)}
>
<Avatar size="md" color="neutral" initials="M" className={avatarClassName} />
<Collapsible collapsed={collapsed}>
<span className="flex items-center gap-0.5">
<span className="text-body-medium whitespace-nowrap text-text-primary">Mertcan Esmergul</span>
<ChevronUpDownSmall className="size-4 shrink-0 text-foreground-icon-tertiary" />
</span>
</Collapsible>
</AriaButton>
<AriaPopover
placement={isMobile ? "bottom start" : "right top"}
offset={8}
className={cx(
"w-[265px] max-w-[calc(100vw-32px)] origin-top-left overflow-y-auto",
"rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown",
"transition duration-150 ease-out",
"data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]",
"data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]",
)}
>
<AriaDialog aria-label="Account menu" className="flex flex-col outline-none">
<AccountMenuContent onSelect={() => setIsOpen(false)} />
</AriaDialog>
</AriaPopover>
</AriaDialogTrigger>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
AccountMenuContent
The dropdown's contents — users-with-access list + Add user/Manage actions — split out so other triggers (e.g. the calendar template's inbox icon) can open the same panel without duplicating it.
| Prop | Type | Default | Description |
|---|---|---|---|
| onSelectrequired | () => void | — | — |
DashboardSidebar
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| flat | boolean | false | Removes the floating panel treatment for a sidebar revealed beneath mobile content. |
| fluid | boolean | false | Expanded width fills its container below `lg` instead of the fixed 260px (e.g. the landing page, where the sidebar isn't in a drawer). Collapsed width stays the fixed 60px rail at every breakpoint — the whole point of collapsing is to shrink, so it must never get overridden back to full width. |
| mobile | boolean | false | Rendered inside the mobile drawer: always expanded, close button instead of collapse. |
| onClose | () => void | — | — |
| selected | "home" | "marketing" | "calendar" | "projects" | "inbox" | "medical" | "profile" | home | Which nav item shows the selected (filled blue) state. |
| showThemeToggle | boolean | true | Hide the app-level theme control when the sidebar is used as marketing artwork. |
DashboardTeamMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| collapsed | boolean | false | — |
DashboardUserMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| avatarClassName | string | — | — |
| collapsed | boolean | false | — |
| onHoverSuppressionEnd | () => void | — | Re-arms hover after the pointer fully leaves the trigger. |
| suppressHover | boolean | false | Prevents expansion from creating a hover state under a stationary pointer. |