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.json

Live 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

PropTypeDefaultDescription
itemsSideMenuNode[]Tree of menu items with optional childNodes.
selectedItemstringControlled active parent id.
selectedChildItemstringControlled active child id.
onMenuItemClick(payload: { id: string; parent?: { id: string } }) => voidFires when a parent or child row is activated.
widthstring | number190pxMaximum width of the menu.
disabledbooleanfalseDisables interaction for the entire menu.
noItemsTextstringNo items to showEmpty-state copy when items is empty.
classNamestringMerged onto the root `<nav>` element.
styleReact.CSSPropertiesInline styles on the root `<nav>` element.
id / title / icon / badge / badgeVariant (SideMenuNode)variousFields on each parent menu item.
childNodes (SideMenuNode)SideMenuChildNode[]Nested rows shown when the parent is expanded.
value / valueClassName / icon (SideMenuChildNode)variousOptional trailing value or icon on child rows.
disabled (SideMenuNode / SideMenuChildNode)booleanDisables an individual row.
onIconClick (SideMenuNode)(item, event) => voidOptional 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

Browse the full examples gallery