App sidebar with collapsible groups, a sliding active indicator, badge counts, and a collapsed icon-rail mode.
npx shadcn@latest add @paragon/sidebar-nav"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface SidebarNavContextValue {
collapsed: boolean;
}
const SidebarNavContext = React.createContext<SidebarNavContextValue | null>(
null,
);
function useSidebarNav() {
const context = React.useContext(SidebarNavContext);
if (!context) {
throw new Error("SidebarNav parts must be used within <SidebarNav>");
}
return context;
}
/**
* Width-collapsing label. The grid column animates 1fr↔0fr (the accordion
* trick rotated 90°) so the sidebar narrows to icons without animating width.
*/
function CollapsingLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={cn(
"grid [grid-template-columns:1fr] transition-[grid-template-columns] duration-[250ms] ease-out group-data-[collapsed]/nav:[grid-template-columns:0fr] motion-reduce:transition-none",
className,
)}
>
<span className="min-w-0 overflow-hidden">
<span className="flex w-40 items-center whitespace-nowrap transition-[opacity,translate] duration-200 ease-out group-data-[collapsed]/nav:-translate-x-1 group-data-[collapsed]/nav:opacity-0 motion-reduce:transition-none">
{children}
</span>
</span>
</span>
);
}
export interface SidebarNavProps extends React.ComponentProps<"nav"> {
/** Collapse to an icon rail; labels slide out and titles take over. */
collapsed?: boolean;
}
/**
* App sidebar with collapsible groups (grid-template-rows 0fr↔1fr), a
* measured indicator that slides along the left edge to the active item,
* badge counts, and a collapsed icon-rail mode where labels fade/slide out
* and native titles stand in as tooltips.
*/
export function SidebarNav({
collapsed = false,
className,
children,
...props
}: SidebarNavProps) {
const navRef = React.useRef<HTMLElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const position = React.useCallback((animate: boolean) => {
const nav = navRef.current;
const indicator = indicatorRef.current;
if (!nav || !indicator) return;
const active = nav.querySelector<HTMLElement>('[data-active="true"]');
const closedGroup = active?.closest<HTMLElement>(
'[data-group-content][data-open="false"]',
);
if (!active || closedGroup) {
indicator.style.opacity = "0";
return;
}
let top = 0;
let node: HTMLElement | null = active;
while (node && node !== nav) {
top += node.offsetTop;
node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;
}
if (!animate) indicator.style.transitionProperty = "none";
indicator.style.opacity = "1";
indicator.style.transform = `translateY(${
top + (active.offsetHeight - 16) / 2
}px)`;
if (!animate) {
void indicator.offsetWidth;
indicator.style.transitionProperty = "";
}
}, []);
// Re-measure after every render; the first placement snaps into position.
React.useLayoutEffect(() => {
position(hasPositioned.current);
hasPositioned.current = true;
});
// Group collapse/expand changes offsets over time — chase them smoothly.
React.useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const observer = new ResizeObserver(() => position(true));
observer.observe(nav);
return () => observer.disconnect();
}, [position]);
const contextValue = React.useMemo(() => ({ collapsed }), [collapsed]);
return (
<nav
ref={navRef}
data-slot="sidebar-nav"
data-collapsed={collapsed || undefined}
className={cn(
"group/nav relative flex w-fit flex-col gap-3 p-2",
className,
)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className="pointer-events-none absolute top-0 left-0 h-4 w-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,opacity] duration-[250ms] ease-out motion-reduce:transition-none"
/>
<SidebarNavContext.Provider value={contextValue}>
{children}
</SidebarNavContext.Provider>
</nav>
);
}
export interface SidebarNavGroupProps extends React.ComponentProps<"div"> {
label: string;
defaultOpen?: boolean;
}
export function SidebarNavGroup({
label,
defaultOpen = true,
className,
children,
...props
}: SidebarNavGroupProps) {
const { collapsed } = useSidebarNav();
const [open, setOpen] = React.useState(defaultOpen);
const contentId = React.useId();
// The icon rail keeps every destination reachable, so collapsed groups
// are forced open.
const isOpen = collapsed || open;
return (
<div className={cn("flex flex-col", className)} {...props}>
<button
type="button"
aria-expanded={isOpen}
aria-controls={contentId}
tabIndex={collapsed ? -1 : 0}
onClick={() => setOpen((prev) => !prev)}
className="relative flex h-7 items-center rounded-md px-2 text-[11px] font-medium tracking-wider text-muted-foreground/80 uppercase transition-colors duration-150 ease-out select-none after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground"
>
<span
aria-hidden
className="pointer-events-none absolute inset-x-2 top-1/2 h-px bg-border opacity-0 transition-opacity duration-150 ease-out group-data-[collapsed]/nav:opacity-100 group-data-[collapsed]/nav:delay-150"
/>
<CollapsingLabel>
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
<ChevronDown
aria-hidden
className={cn(
"ml-auto size-3 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none",
!open && "-rotate-90",
)}
/>
</CollapsingLabel>
</button>
<div
id={contentId}
data-group-content=""
data-open={isOpen}
className="grid [grid-template-rows:0fr] transition-[grid-template-rows] duration-[250ms] ease-out data-[open=true]:[grid-template-rows:1fr] motion-reduce:transition-none"
>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-col gap-0.5 pt-0.5">{children}</div>
</div>
</div>
</div>
);
}
export interface SidebarNavItemProps extends React.ComponentProps<"a"> {
icon?: React.ReactNode;
/** Count or short text rendered at the trailing edge. */
badge?: React.ReactNode;
active?: boolean;
}
export function SidebarNavItem({
icon,
badge,
active = false,
className,
children,
...props
}: SidebarNavItemProps) {
const { collapsed } = useSidebarNav();
return (
<a
data-slot="sidebar-nav-item"
data-active={active || undefined}
title={
collapsed && typeof children === "string" ? children : undefined
}
className={cn(
"relative flex h-8 items-center rounded-md px-2 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent/60 hover:text-foreground data-active:bg-accent data-active:text-accent-foreground",
className,
)}
{...props}
>
<span
aria-hidden
className="flex size-5 shrink-0 items-center justify-center [&_svg]:size-4"
>
{icon}
</span>
<CollapsingLabel>
<span className="min-w-0 flex-1 truncate pl-2">{children}</span>
{badge != null && (
<span className="ml-auto inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-secondary px-1.5 text-[11px] font-medium text-muted-foreground tabular-nums">
{badge}
</span>
)}
</CollapsingLabel>
{badge != null && (
<span
aria-hidden
className="absolute top-1.5 right-1.5 size-1 rounded-full bg-foreground/50 opacity-0 transition-opacity duration-150 ease-out group-data-[collapsed]/nav:opacity-100 group-data-[collapsed]/nav:delay-150"
/>
)}
</a>
);
}