Composite
SideMenu
SideMenu renders a tree of navigation items with optional nested child rows. Supports controlled and uncontrolled selection, single-open expansion, trailing badges and icons, and disabled rows.
Installation
npx shadcn add https://rck.vibeboxph.com//r/side-menu.jsonLive preview & controls
Controls
<SideMenu
items={menuItems}
/>Anatomy
SideMenuThe root `<nav>` container.Parent rowA top-level item; expands when it has child rows.Child rowA nested item shown when its parent is expanded.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | SideMenuNode[] | — | Tree of menu items with optional childNodes. |
| selectedItem | string | — | Controlled active parent id. |
| selectedChildItem | string | — | Controlled active child id. |
| onMenuItemClick | (payload: { id: string; parent?: { id: string } }) => void | — | Fires when a parent or child row is activated. |
| width | string | number | 190px | Maximum width of the menu. |
| disabled | boolean | false | Disables interaction for the entire menu. |
| noItemsText | string | No items to show | Empty-state copy when items is empty. |
| className | string | — | Merged onto the root `<nav>` element. |
| style | React.CSSProperties | — | Inline styles on the root `<nav>` element. |
| id / title / icon / badge / badgeVariant (SideMenuNode) | various | — | Fields on each parent menu item. |
| childNodes (SideMenuNode) | SideMenuChildNode[] | — | Nested rows shown when the parent is expanded. |
| value / valueClassName / icon (SideMenuChildNode) | various | — | Optional trailing value or icon on child rows. |
| disabled (SideMenuNode / SideMenuChildNode) | boolean | — | Disables an individual row. |
| onIconClick (SideMenuNode) | (item, event) => void | — | Optional handler on the trailing parent icon. |
Accessibility
- Renders as `<nav aria-label="Side menu">` with button rows for each item.
- Parent rows with children expose `aria-expanded`; the active row sets `aria-current="page"`.
- Disabled rows are removed from interaction with native `disabled` on buttons.
Source
registry/rck/ui/side-menu.tsx
"use client";
import * as React from "react";
import { Badge, type BadgeProps } from "@/registry/rck/ui/badge";
import { Icon, type IconName } from "@/registry/rck/ui/icon";
import { cn } from "@/lib/utils";
export const ROW_HEIGHT_PX = 30;
export type SideMenuChildNode = {
id: string;
title: string;
value?: number;
valueClassName?: string;
icon?: IconName;
disabled?: boolean;
};
export type SideMenuNode = {
id: string;
title: string;
icon?: IconName;
badge?: number;
badgeVariant?: BadgeProps["variant"];
childNodes?: SideMenuChildNode[];
disabled?: boolean;
onIconClick?: (item: SideMenuNode, e: React.MouseEvent) => void;
};
export type SideMenuClickPayload = {
id: string;
parent?: { id: string };
};
export interface SideMenuProps {
items?: SideMenuNode[] | null;
selectedItem?: string;
selectedChildItem?: string;
onMenuItemClick?: (payload: SideMenuClickPayload) => void;
width?: string | number;
disabled?: boolean;
noItemsText?: string;
className?: string;
style?: React.CSSProperties;
}
function ensureSelection(
items: SideMenuNode[],
selectedItem: string | undefined,
selectedChildItem: string | undefined,
): { selectedItem: string | undefined; selectedChildItem: string | undefined } {
if (items.length === 0) return { selectedItem, selectedChildItem };
if (!selectedItem) {
const parent = items[0];
const firstChild = parent.childNodes?.[0];
return {
selectedItem: parent.id,
selectedChildItem: firstChild ? firstChild.id : undefined,
};
}
const parent = items.find((item) => item.id === selectedItem) ?? items[0];
if (!parent) return { selectedItem: undefined, selectedChildItem: undefined };
if (parent.childNodes && parent.childNodes.length > 0) {
const hasChild = parent.childNodes.find((child) => child.id === selectedChildItem);
return {
selectedItem: parent.id,
selectedChildItem: hasChild ? hasChild.id : parent.childNodes[0].id,
};
}
return { selectedItem: parent.id, selectedChildItem: undefined };
}
function formatBadge(value: number): { label: string; title?: string } {
if (value > 99) return { label: "99+", title: String(value) };
return { label: String(value) };
}
type ParentRowProps = {
item: SideMenuNode;
expanded: boolean;
active: boolean;
selectedChildItem: string | undefined;
activeLocalId: string | undefined;
onParentClick: (item: SideMenuNode) => void;
onChildClick: (child: SideMenuChildNode, parentId: string) => void;
};
const ParentRow = React.memo(function ParentRow({
item,
expanded,
active,
selectedChildItem,
activeLocalId,
onParentClick,
onChildClick,
}: ParentRowProps) {
const hasChild = !!item.childNodes?.length;
const targetHeightPx = hasChild ? item.childNodes!.length * ROW_HEIGHT_PX : 0;
const badge = item.badge != null ? formatBadge(item.badge) : null;
return (
<div
className={cn(
"border-border border-b transition-all duration-200",
active && "bg-primary-soft text-primary",
item.disabled && "pointer-events-none opacity-40",
!item.disabled && "hover:[&_.side-menu-title]:text-primary",
)}
data-expanded={expanded || undefined}
>
<button
type="button"
disabled={item.disabled}
aria-expanded={hasChild ? expanded : undefined}
aria-current={active && !hasChild ? "page" : undefined}
className={cn(
"flex h-[30px] w-full items-center justify-between px-3 pl-4 text-left text-sm transition-colors duration-200",
active && "font-semibold shadow-sm",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none",
)}
onClick={() => onParentClick(item)}
>
<span
className={cn(
"side-menu-title text-foreground relative flex min-w-0 flex-1 items-center truncate transition-colors duration-200",
active && "text-primary",
hasChild && "pl-[18px]",
)}
>
{hasChild && (
<span
className={cn(
"absolute left-0 flex size-2 items-center justify-center transition-transform duration-200",
expanded && "rotate-90",
)}
aria-hidden="true"
>
<Icon name="chevron-right" size="xs" />
</span>
)}
<span className="truncate">{item.title || "[No Title]"}</span>
</span>
<span className="ml-1.5 inline-flex shrink-0 items-center gap-1.5">
{item.icon && (
<span
className="inline-flex"
onClick={
item.onIconClick
? (event) => {
event.stopPropagation();
item.onIconClick?.(item, event);
}
: undefined
}
onKeyDown={
item.onIconClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.stopPropagation();
event.preventDefault();
item.onIconClick?.(item, event as unknown as React.MouseEvent);
}
}
: undefined
}
role={item.onIconClick ? "button" : undefined}
tabIndex={item.onIconClick ? 0 : undefined}
>
<Icon name={item.icon} size="xs" className="text-muted-foreground" />
</span>
)}
{badge && (
<Badge
variant={item.badgeVariant ?? "soft"}
size="xs"
title={badge.title}
className="min-w-[1.25rem] justify-center rounded px-1"
>
{badge.label}
</Badge>
)}
</span>
</button>
{hasChild && (
<div
className={cn(
"bg-surface-2/60 overflow-hidden pr-3 pl-5 transition-[max-height,opacity] duration-200 ease-out",
expanded ? "opacity-100" : "pointer-events-none opacity-0",
)}
style={{ maxHeight: expanded ? `${targetHeightPx}px` : "0px" }}
aria-hidden={!expanded}
>
{item.childNodes!.map((child) => {
const childActive =
selectedChildItem === child.id || activeLocalId === child.id;
return (
<button
key={child.id}
type="button"
disabled={child.disabled}
aria-current={childActive ? "page" : undefined}
className={cn(
"border-border/60 flex h-[30px] w-full items-center justify-between border-b pl-6 text-left text-xs transition-colors duration-200 last:border-b-0",
childActive
? "text-primary font-semibold"
: "text-muted-foreground hover:text-primary",
child.disabled && "pointer-events-none opacity-40",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none",
)}
onClick={() => onChildClick(child, item.id)}
>
<span className="truncate">{child.title || "[No Title]"}</span>
<span className="ml-1.5 shrink-0">
{typeof child.value === "number" ? (
<strong
className={cn("text-xs", child.valueClassName ?? "text-primary")}
>
{child.value}
</strong>
) : child.icon ? (
<Icon name={child.icon} size="xs" className="text-muted-foreground" />
) : null}
</span>
</button>
);
})}
</div>
)}
</div>
);
});
export const SideMenu = React.forwardRef<HTMLElement, SideMenuProps>(function SideMenu(
{
items,
disabled = false,
noItemsText = "No items to show",
width = "190px",
selectedItem: selectedItemProp,
selectedChildItem: selectedChildItemProp,
onMenuItemClick,
className,
style,
},
ref,
) {
const isItemControlled = selectedItemProp !== undefined;
const isChildControlled = selectedChildItemProp !== undefined;
const [selItem, setSelItem] = React.useState<string | undefined>(selectedItemProp);
const [selChild, setSelChild] = React.useState<string | undefined>(
selectedChildItemProp,
);
const [activeLocalId, setActiveLocalId] = React.useState<string | undefined>(undefined);
const [expandedMap, setExpandedMap] = React.useState<Record<string, boolean>>({});
const internalItems = items ?? undefined;
React.useEffect(() => {
if (isItemControlled) setSelItem(selectedItemProp);
setActiveLocalId(undefined);
}, [isItemControlled, selectedItemProp]);
React.useEffect(() => {
if (isChildControlled) setSelChild(selectedChildItemProp);
}, [isChildControlled, selectedChildItemProp]);
React.useEffect(() => {
if (!internalItems?.length) return;
const { selectedItem: nextItem, selectedChildItem: nextChild } = ensureSelection(
internalItems,
isItemControlled ? selectedItemProp : selItem,
isChildControlled ? selectedChildItemProp : selChild,
);
if (!isItemControlled) setSelItem(nextItem);
if (!isChildControlled) setSelChild(nextChild);
const activeId = isItemControlled ? selectedItemProp : nextItem;
const nextExpanded: Record<string, boolean> = {};
internalItems.forEach(
(node) =>
(nextExpanded[node.id] = node.id === activeId && !!node.childNodes?.length),
);
setExpandedMap(nextExpanded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalItems]);
React.useEffect(() => {
if (!internalItems?.length || !isItemControlled) return;
const nextExpanded: Record<string, boolean> = {};
internalItems.forEach(
(node) =>
(nextExpanded[node.id] =
node.id === selectedItemProp && !!node.childNodes?.length),
);
setExpandedMap(nextExpanded);
}, [isItemControlled, selectedItemProp, internalItems]);
const setExpandedSingle = React.useCallback(
(id?: string) => {
setExpandedMap(() => {
const next: Record<string, boolean> = {};
(internalItems ?? []).forEach(
(node) => (next[node.id] = !!id && node.id === id && !!node.childNodes?.length),
);
return next;
});
},
[internalItems],
);
const handleParentClick = React.useCallback(
(item: SideMenuNode) => {
if (item.disabled) return;
onMenuItemClick?.({ id: item.id });
const hasChild = !!item.childNodes?.length;
if (isItemControlled) {
if (hasChild) {
const isOpen = !!expandedMap[item.id];
setExpandedSingle(isOpen ? undefined : item.id);
} else {
setActiveLocalId(item.id);
}
return;
}
const nextIsSelected = selItem !== item.id ? item.id : undefined;
let nextChild: string | undefined;
if (nextIsSelected && hasChild) {
nextChild = item.childNodes?.[0]?.id;
}
setSelItem(nextIsSelected);
setSelChild(nextChild);
setExpandedSingle(nextIsSelected);
},
[isItemControlled, expandedMap, setExpandedSingle, selItem, onMenuItemClick],
);
const handleChildClick = React.useCallback(
(child: SideMenuChildNode, parentId: string) => {
if (child.disabled) return;
onMenuItemClick?.({ id: child.id, parent: { id: parentId } });
if (isItemControlled) {
setExpandedSingle(parentId);
setActiveLocalId(child.id);
return;
}
setSelItem(parentId);
setSelChild(child.id);
setExpandedSingle(parentId);
},
[isItemControlled, setExpandedSingle, onMenuItemClick],
);
const resolvedWidth = typeof width === "number" ? `${width}px` : width;
const controllerActiveId = isItemControlled ? selectedItemProp : selItem;
const controllerChildId = isChildControlled ? selectedChildItemProp : selChild;
if (!internalItems?.length) {
return (
<nav
ref={ref}
aria-label="Side menu"
className={cn(
"inline-block w-full max-w-full",
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ maxWidth: resolvedWidth, ...style }}
data-disabled={disabled || undefined}
>
<div className="border-border bg-surface-2/60 text-muted-foreground border border-dashed px-3 py-2.5 text-xs">
{noItemsText}
</div>
</nav>
);
}
return (
<nav
ref={ref}
aria-label="Side menu"
className={cn(
"inline-block w-full max-w-full",
disabled && "pointer-events-none opacity-50",
className,
)}
style={{ maxWidth: resolvedWidth, ...style }}
data-disabled={disabled || undefined}
>
<div className="animate-in fade-in duration-200">
{internalItems.map((item) => {
const active =
controllerActiveId === item.id ||
(!item.childNodes?.length && activeLocalId === item.id);
const expanded = expandedMap[item.id] || false;
return (
<ParentRow
key={item.id}
item={item}
expanded={expanded}
active={active}
selectedChildItem={controllerChildId}
activeLocalId={activeLocalId}
onParentClick={handleParentClick}
onChildClick={handleChildClick}
/>
);
})}
</div>
</nav>
);
});
SideMenu.displayName = "SideMenu";
Related examples
VariantsBadges, icons, child values and a disabled row.
ControlledExternal buttons drive the active selection.