Home/Components/Sidebars/Sidebar - React Admin Dashboard Sidebar
Code · Live Preview
Componenttsx
// src/components/Sidebar/Sidebar.tsx
import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type MouseEvent as ReactMouseEvent,
  type SyntheticEvent,
} from "react";
import {
  ChevronRight,
  ChevronsUpDown,
  LogOut,
  PanelLeftClose,
  PanelLeftOpen,
  Search,
  Settings,
  UserRound,
  X,
} from "lucide-react";
import type {
  SidebarBrand,
  SidebarItem,
  SidebarLink,
  SidebarSection,
  SidebarSubItem,
  SidebarUser,
} from "./sidebarLinks";

/* ------------------------------------------------------------------ */
/*  Props                                                              */
/* ------------------------------------------------------------------ */

export interface SidebarProps {
  sections: SidebarSection[];
  brand: SidebarBrand;
  user?: SidebarUser;
  /** Any hex color. Drives active states, focus rings and the logo. */
  accentColor?: string;

  /** Controlled collapsed state (desktop). Omit to let the sidebar handle it. */
  collapsed?: boolean;
  defaultCollapsed?: boolean;
  onCollapsedChange?: (collapsed: boolean) => void;

  /** Mobile drawer */
  mobileOpen?: boolean;
  onMobileClose?: () => void;

  /** Controlled active link id. Omit to let the sidebar handle it. */
  activeId?: string;
  defaultActiveId?: string;
  /** Fires on every link click. Plug your router in here. */
  onNavigate?: (
    link: SidebarLink,
    event: ReactMouseEvent<HTMLAnchorElement>,
  ) => void;

  /** Search button + Ctrl/⌘K shortcut */
  onSearch?: () => void;
  onProfile?: () => void;
  onSettings?: () => void;
  onLogout?: () => void;
}

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */

const cx = (...classes: Array<string | false | null | undefined>) =>
  classes.filter(Boolean).join(" ");

const isDesktop = () =>
  typeof window !== "undefined" &&
  window.matchMedia("(min-width: 768px)").matches;
const isMac = () =>
  typeof navigator !== "undefined" &&
  /Mac|iPhone|iPad/.test(navigator.userAgent);

function hexToRgba(hex: string, alpha: number) {
  const h = hex.replace("#", "");
  const full =
    h.length === 3
      ? h
          .split("")
          .map((c) => c + c)
          .join("")
      : h;
  const n = parseInt(full, 16);
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}

function findParentId(sections: SidebarSection[], id?: string) {
  if (!id) return undefined;
  for (const s of sections)
    for (const item of s.items)
      if (item.children?.some((c) => c.id === id)) return item.id;
  return undefined;
}

const firstLinkId = (sections: SidebarSection[]) => {
  const first = sections[0]?.items[0];
  return first?.children?.[0]?.id ?? first?.id;
};

const focusRing =
  "outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sb-accent)] focus-visible:ring-offset-0";

type RowState = {
  active?: boolean;
  parentActive?: boolean;
  disabled?: boolean;
  pressed?: boolean;
};

/** Classes for a top-level row, in every state */
function rowClass(
  { active, parentActive, disabled, pressed }: RowState,
  collapsed: boolean,
) {
  return cx(
    "group relative flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13.5px] font-medium",
    "transition-colors duration-150 motion-reduce:transition-none",
    focusRing,
    active && "bg-white/[0.07] text-white",
    !active && parentActive && "text-zinc-100",
    !active && parentActive && collapsed && "md:bg-white/[0.07]",
    !active &&
      !parentActive &&
      !disabled &&
      "cursor-pointer text-zinc-400 hover:bg-white/[0.04] hover:text-zinc-100",
    pressed && "bg-white/[0.06] text-zinc-100",
    disabled && "cursor-not-allowed text-zinc-600",
    collapsed && "md:mx-auto md:h-10 md:w-10 md:justify-center md:px-0",
  );
}

function iconClass({ active, parentActive, disabled }: RowState) {
  return cx(
    "h-[18px] w-[18px] shrink-0 transition-colors",
    (active || parentActive) && "text-[color:var(--sb-accent)]",
    !active &&
      !parentActive &&
      !disabled &&
      "text-zinc-500 group-hover:text-zinc-300",
    disabled && "text-zinc-700",
  );
}

function Badge({
  value,
  active,
  disabled,
  className,
}: {
  value: string | number;
  active?: boolean;
  disabled?: boolean;
  className?: string;
}) {
  const isCount = typeof value === "number";
  return (
    <span
      className={cx(
        "ml-auto inline-flex h-5 min-w-[20px] items-center justify-center rounded-md px-1.5 text-[11px] font-semibold tabular-nums leading-none",
        disabled
          ? "bg-white/[0.03] text-zinc-600"
          : isCount
            ? active
              ? "bg-white/10 text-white"
              : "bg-white/[0.06] text-zinc-400"
            : "bg-[color:var(--sb-accent-soft)] text-[color:var(--sb-accent)]",
        className,
      )}
    >
      {value}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/*  Sidebar                                                            */
/* ------------------------------------------------------------------ */

type Floating = { top: number; left: number };

export default function Sidebar({
  sections,
  brand,
  user,
  accentColor = "#4F8CFF",
  collapsed: collapsedProp,
  defaultCollapsed = false,
  onCollapsedChange,
  mobileOpen = false,
  onMobileClose,
  activeId,
  defaultActiveId,
  onNavigate,
  onSearch,
  onProfile,
  onSettings,
  onLogout,
}: SidebarProps) {
  /* ---- collapsed (controlled or not) ---- */
  const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
  const collapsed = collapsedProp ?? internalCollapsed;
  const setCollapsed = (value: boolean) => {
    if (collapsedProp === undefined) setInternalCollapsed(value);
    onCollapsedChange?.(value);
  };

  /* ---- active link (controlled or not) ---- */
  const [internalActive, setInternalActive] = useState(
    defaultActiveId ?? firstLinkId(sections),
  );
  const active = activeId ?? internalActive;

  /* ---- open dropdown groups ---- */
  const [openGroups, setOpenGroups] = useState<Set<string>>(() => {
    const parent = findParentId(sections, active);
    return new Set(parent ? [parent] : []);
  });

  // If the active link lives inside a group, make sure that group is open
  useEffect(() => {
    const parent = findParentId(sections, active);
    if (parent)
      setOpenGroups((prev) =>
        prev.has(parent) ? prev : new Set(prev).add(parent),
      );
  }, [active, sections]);

  const toggleGroup = (id: string) =>
    setOpenGroups((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  /* ---- floating UI for the collapsed rail ---- */
  const [tooltip, setTooltip] = useState<(Floating & { label: string }) | null>(
    null,
  );
  const [flyout, setFlyout] = useState<
    (Floating & { item: SidebarItem }) | null
  >(null);
  const flyoutRef = useRef<HTMLDivElement>(null);

  const rail = collapsed ? "md:hidden" : ""; // hide text in the desktop rail

  /* ---- effects ---- */
  useEffect(() => {
    if (!collapsed) {
      setTooltip(null);
      setFlyout(null);
    }
  }, [collapsed]);

  // Mobile drawer: Escape to close + lock page scroll
  useEffect(() => {
    if (!mobileOpen) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onMobileClose?.();
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    window.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prev;
      window.removeEventListener("keydown", onKey);
    };
  }, [mobileOpen, onMobileClose]);

  // Ctrl/⌘ + K
  useEffect(() => {
    if (!onSearch) return;
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        onSearch();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onSearch]);

  // Close the rail flyout on outside click / Escape
  useEffect(() => {
    if (!flyout) return;
    const onDown = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      if (
        flyoutRef.current?.contains(target) ||
        target.closest("[data-sb-group]")
      )
        return;
      setFlyout(null);
    };
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setFlyout(null);
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [flyout]);

  /* ---- handlers ---- */
  const navigate = (
    link: SidebarLink,
    event: ReactMouseEvent<HTMLAnchorElement>,
  ) => {
    if (link.disabled) {
      event.preventDefault();
      return;
    }
    if (activeId === undefined) setInternalActive(link.id);
    onNavigate?.(link, event);
    setFlyout(null);
    onMobileClose?.();
  };

  const showTooltip = (event: SyntheticEvent<HTMLElement>, label: string) => {
    if (!collapsed || !isDesktop() || flyout) return;
    const r = event.currentTarget.getBoundingClientRect();
    setTooltip({ label, top: r.top + r.height / 2, left: r.right + 12 });
  };
  const hideTooltip = () => setTooltip(null);

  const handleGroupClick = (
    item: SidebarItem,
    event: ReactMouseEvent<HTMLButtonElement>,
  ) => {
    if (collapsed && isDesktop()) {
      hideTooltip();
      const r = event.currentTarget.getBoundingClientRect();
      const estHeight = 48 + (item.children?.length ?? 0) * 34;
      const top = Math.max(
        8,
        Math.min(r.top - 6, window.innerHeight - estHeight - 8),
      );
      setFlyout((f) =>
        f?.item.id === item.id ? null : { item, top, left: r.right + 12 },
      );
      return;
    }
    toggleGroup(item.id);
  };

  const style = {
    "--sb-accent": accentColor,
    "--sb-accent-soft": hexToRgba(accentColor, 0.14),
  } as CSSProperties;

  /* ---- renderers ---- */
  const renderSubLink = (
    sub: SidebarSubItem,
    tabbable: boolean,
    inFlyout = false,
  ) => {
    const isActive = sub.id === active;
    return (
      <li key={sub.id}>
        <a
          href={sub.disabled ? undefined : sub.href}
          role="link"
          onClick={(e) => navigate(sub, e)}
          aria-current={isActive ? "page" : undefined}
          aria-disabled={sub.disabled || undefined}
          tabIndex={tabbable && !sub.disabled ? 0 : -1}
          className={cx(
            "relative flex h-8 items-center gap-2 rounded-md px-2.5 text-[13px] transition-colors duration-150 motion-reduce:transition-none",
            focusRing,
            isActive && "bg-white/[0.05] font-medium text-white",
            !isActive &&
              !sub.disabled &&
              "cursor-pointer text-zinc-400 hover:bg-white/[0.03] hover:text-zinc-100",
            sub.disabled && "cursor-not-allowed text-zinc-600",
          )}
        >
          {/* accent segment on the guide line */}
          {isActive && !inFlyout && (
            <span
              aria-hidden
              className="absolute -left-[10px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-[color:var(--sb-accent)]"
            />
          )}
          {isActive && inFlyout && (
            <span
              aria-hidden
              className="h-1.5 w-1.5 shrink-0 rounded-full bg-[color:var(--sb-accent)]"
            />
          )}
          <span className="truncate">{sub.label}</span>
          {sub.badge !== undefined && (
            <Badge
              value={sub.badge}
              active={isActive}
              disabled={sub.disabled}
            />
          )}
        </a>
      </li>
    );
  };

  const renderLeaf = (item: SidebarItem) => {
    const Icon = item.icon;
    const state: RowState = {
      active: item.id === active,
      disabled: item.disabled,
    };
    return (
      <a
        href={item.disabled ? undefined : item.href}
        role="link"
        onClick={(e) => navigate(item, e)}
        onMouseEnter={(e) => showTooltip(e, item.label)}
        onMouseLeave={hideTooltip}
        onFocus={(e) => showTooltip(e, item.label)}
        onBlur={hideTooltip}
        aria-current={state.active ? "page" : undefined}
        aria-disabled={item.disabled || undefined}
        aria-label={collapsed ? item.label : undefined}
        tabIndex={item.disabled ? -1 : 0}
        className={rowClass(state, collapsed)}
      >
        {state.active && (
          <span
            aria-hidden
            className="absolute -left-3 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[color:var(--sb-accent)]"
          />
        )}
        <Icon className={iconClass(state)} strokeWidth={1.75} />
        <span className={cx("flex-1 truncate text-left", rail)}>
          {item.label}
        </span>
        {item.badge !== undefined && (
          <Badge
            value={item.badge}
            active={state.active}
            disabled={item.disabled}
            className={rail}
          />
        )}
        {item.badge !== undefined && !item.disabled && (
          <span
            aria-hidden
            className={cx(
              "absolute right-1.5 top-1.5 hidden h-1.5 w-1.5 rounded-full bg-[color:var(--sb-accent)] ring-2 ring-[#121214]",
              collapsed && "md:block",
            )}
          />
        )}
      </a>
    );
  };

  const renderGroup = (item: SidebarItem) => {
    const Icon = item.icon;
    const children = item.children ?? [];
    const open = openGroups.has(item.id);
    const childActive = children.some((c) => c.id === active);
    const flyoutOpen = flyout?.item.id === item.id;
    const panelId = `sb-group-${item.id}`;
    const state: RowState = {
      parentActive: childActive,
      disabled: item.disabled,
      pressed: flyoutOpen,
    };

    return (
      <>
        <button
          type="button"
          data-sb-group
          onClick={(e) => handleGroupClick(item, e)}
          onMouseEnter={(e) => showTooltip(e, item.label)}
          onMouseLeave={hideTooltip}
          onFocus={(e) => showTooltip(e, item.label)}
          onBlur={hideTooltip}
          aria-expanded={collapsed ? flyoutOpen : open}
          aria-controls={panelId}
          aria-label={collapsed ? item.label : undefined}
          disabled={item.disabled}
          className={rowClass(state, collapsed)}
        >
          {childActive && collapsed && (
            <span
              aria-hidden
              className="absolute -left-3 top-1/2 hidden h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[color:var(--sb-accent)] md:block"
            />
          )}
          <Icon className={iconClass(state)} strokeWidth={1.75} />
          <span className={cx("flex-1 truncate text-left", rail)}>
            {item.label}
          </span>
          {item.badge !== undefined && (
            <Badge
              value={item.badge}
              disabled={item.disabled}
              className={rail}
            />
          )}
          <ChevronRight
            className={cx(
              "h-4 w-4 shrink-0 text-zinc-500 transition-transform duration-200 motion-reduce:transition-none",
              open && "rotate-90",
              rail,
            )}
          />
        </button>

        {/* Expandable panel (expanded sidebar + mobile) */}
        <div
          id={panelId}
          className={cx(
            "grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
            open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
            collapsed && "md:hidden",
          )}
        >
          <div className="min-h-0 overflow-hidden">
            <ul className="ml-[19px] mt-0.5 space-y-0.5 border-l border-white/[0.08] py-0.5 pl-2">
              {children.map((sub) => renderSubLink(sub, open))}
            </ul>
          </div>
        </div>
      </>
    );
  };

  const BrandIcon = brand.icon;
  const logoMark = (
    <span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[color:var(--sb-accent)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_4px_12px_-2px_var(--sb-accent-soft)]">
      <BrandIcon className="h-[18px] w-[18px]" strokeWidth={2.25} />
    </span>
  );

  return (
    <>
      {/* Mobile overlay */}
      <div
        aria-hidden
        onClick={onMobileClose}
        className={cx(
          "fixed inset-0 z-40 bg-black/60 backdrop-blur-[2px] transition-opacity duration-300 motion-reduce:transition-none md:hidden",
          mobileOpen ? "opacity-100" : "pointer-events-none opacity-0",
        )}
      />

      <aside
        aria-label="Sidebar"
        style={style}
        className={cx(
          "fixed inset-y-0 left-0 z-50 flex w-[280px] flex-col border-r border-white/[0.07] bg-[#121214] text-zinc-300",
          "shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",
          "transition-[transform,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
          "md:sticky md:top-2 md:z-40 md:m-2 md:h-[calc(100vh-1rem)] md:shrink-0 md:translate-x-0 md:rounded-2xl md:border",
          mobileOpen ? "translate-x-0" : "-translate-x-full",
          collapsed ? "md:w-[68px]" : "md:w-[260px]",
        )}
      >
        {/* Header */}
        <div
          className={cx(
            "flex h-16 shrink-0 items-center gap-2 px-4",
            collapsed && "md:justify-center md:px-0",
          )}
        >
          <a
            href={brand.href}
            className={cx(
              "flex min-w-0 items-center gap-2.5 rounded-lg",
              focusRing,
              collapsed && "md:hidden",
            )}
          >
            {logoMark}
            <span className="truncate text-[15px] font-semibold tracking-tight text-white">
              {brand.name}
              {brand.suffix && (
                <span className="text-zinc-500">{brand.suffix}</span>
              )}
            </span>
          </a>

          {/* Rail: logo turns into an expand button on hover */}
          <button
            type="button"
            onClick={() => setCollapsed(false)}
            aria-label="Expand sidebar"
            className={cx(
              "group/logo relative h-8 w-8 place-items-center rounded-lg",
              focusRing,
              collapsed ? "hidden md:grid" : "hidden",
            )}
          >
            <span className="transition-opacity duration-150 group-hover/logo:opacity-0 group-focus-visible/logo:opacity-0">
              {logoMark}
            </span>
            <span className="absolute inset-0 grid place-items-center rounded-lg bg-white/[0.06] text-zinc-200 opacity-0 transition-opacity duration-150 group-hover/logo:opacity-100 group-focus-visible/logo:opacity-100">
              <PanelLeftOpen className="h-[18px] w-[18px]" strokeWidth={1.75} />
            </span>
          </button>

          <button
            type="button"
            onClick={() => setCollapsed(true)}
            aria-label="Collapse sidebar"
            className={cx(
              "ml-auto h-8 w-8 place-items-center rounded-md text-zinc-500 transition-colors hover:bg-white/[0.06] hover:text-zinc-200",
              focusRing,
              collapsed ? "hidden" : "hidden md:grid",
            )}
          >
            <PanelLeftClose className="h-[18px] w-[18px]" strokeWidth={1.75} />
          </button>

          <button
            type="button"
            onClick={onMobileClose}
            aria-label="Close sidebar"
            className={cx(
              "ml-auto grid h-8 w-8 place-items-center rounded-md text-zinc-400 hover:bg-white/[0.06] hover:text-white md:hidden",
              focusRing,
            )}
          >
            <X className="h-[18px] w-[18px]" />
          </button>
        </div>

        {/* Search */}
        <div className={cx("shrink-0 px-3 pb-3", collapsed && "md:px-0")}>
          <button
            type="button"
            onClick={onSearch}
            onMouseEnter={(e) => showTooltip(e, "Search")}
            onMouseLeave={hideTooltip}
            onFocus={(e) => showTooltip(e, "Search")}
            onBlur={hideTooltip}
            aria-label="Search"
            className={cx(
              "flex h-9 w-full items-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.025] px-2.5 text-[13px] text-zinc-500",
              "transition-colors hover:border-white/[0.12] hover:bg-white/[0.04] hover:text-zinc-300",
              focusRing,
              collapsed && "md:mx-auto md:w-10 md:justify-center md:px-0",
            )}
          >
            <Search className="h-4 w-4 shrink-0" strokeWidth={1.75} />
            <span
              className={cx(
                "flex-1 truncate whitespace-nowrap text-left",
                rail,
              )}
            >
              Search…
            </span>
            <kbd
              className={cx(
                "rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-sans text-[10.5px] font-medium text-zinc-500",
                rail,
              )}
            >
              {isMac() ? "⌘K" : "Ctrl K"}
            </kbd>
          </button>
        </div>

        {/* Links */}
        <nav
          aria-label="Main"
          onScroll={() => {
            hideTooltip();
            setFlyout(null);
          }}
          className={cx(
            "flex-1 overflow-y-auto overflow-x-hidden px-3 pb-4",
            "[scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,0.12)_transparent]",
            "[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-white/10",
          )}
        >
          {sections.map((section, index) => (
            <div key={section.id} className={index === 0 ? "pt-1" : "mt-5"}>
              <p
                className={cx(
                  "px-2.5 pb-1.5 text-[11.5px] font-medium text-zinc-500",
                  rail,
                )}
              >
                {section.title}
              </p>
              {index > 0 && (
                <div
                  aria-hidden
                  className={cx(
                    "mx-auto mb-3 hidden h-px w-6 bg-white/[0.08]",
                    collapsed && "md:block",
                  )}
                />
              )}

              <ul className="space-y-0.5">
                {section.items.map((item) => (
                  <li key={item.id}>
                    {item.children?.length
                      ? renderGroup(item)
                      : renderLeaf(item)}
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>

        {user && (
          <UserMenu
            user={user}
            collapsed={collapsed}
            rail={rail}
            onProfile={onProfile}
            onSettings={onSettings}
            onLogout={onLogout}
          />
        )}
      </aside>

      {/* Rail tooltip */}
      {tooltip && (
        <div
          role="tooltip"
          style={{ top: tooltip.top, left: tooltip.left }}
          className="pointer-events-none fixed z-[60] -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-zinc-800 px-2 py-1 text-xs font-medium text-zinc-100 shadow-lg shadow-black/40"
        >
          {tooltip.label}
        </div>
      )}

      {/* Rail flyout for dropdown groups */}
      {flyout && (
        <div
          ref={flyoutRef}
          style={{ ...style, top: flyout.top, left: flyout.left }}
          className="fixed z-[60] w-56 rounded-xl border border-white/10 bg-[#18181b] p-1.5 shadow-2xl shadow-black/60"
        >
          <p className="px-2.5 pb-1.5 pt-1 text-[11.5px] font-medium text-zinc-500">
            {flyout.item.label}
          </p>
          <ul className="space-y-0.5">
            {flyout.item.children?.map((sub) => renderSubLink(sub, true, true))}
          </ul>
        </div>
      )}
    </>
  );
}

/* ------------------------------------------------------------------ */
/*  User menu                                                          */
/* ------------------------------------------------------------------ */

interface UserMenuProps {
  user: SidebarUser;
  collapsed: boolean;
  rail: string;
  onProfile?: () => void;
  onSettings?: () => void;
  onLogout?: () => void;
}

function UserMenu({
  user,
  collapsed,
  rail,
  onProfile,
  onSettings,
  onLogout,
}: UserMenuProps) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node))
        setOpen(false);
    };
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const run = (fn?: () => void) => () => {
    setOpen(false);
    fn?.();
  };

  const itemClass = cx(
    "flex h-8 w-full items-center gap-2.5 rounded-md px-2.5 text-[13px] text-zinc-300 transition-colors hover:bg-white/[0.06] hover:text-white",
    focusRing,
  );

  return (
    <div
      ref={ref}
      className="relative shrink-0 border-t border-white/[0.06] p-2"
    >
      <div
        role="menu"
        aria-hidden={!open}
        className={cx(
          "absolute bottom-full left-2 right-2 mb-2 origin-bottom rounded-xl border border-white/10 bg-[#18181b] p-1.5 shadow-2xl shadow-black/60",
          "transition duration-150 ease-out motion-reduce:transition-none",
          collapsed &&
            "md:bottom-2 md:left-full md:right-auto md:mb-0 md:ml-3 md:w-60 md:origin-bottom-left",
          open
            ? "visible translate-y-0 scale-100 opacity-100"
            : "invisible translate-y-1 scale-[0.98] opacity-0",
        )}
      >
        <div className="flex items-center gap-2.5 px-2.5 pb-2.5 pt-1.5">
          <Avatar user={user} />
          <div className="min-w-0">
            <p className="truncate text-[13px] font-semibold text-zinc-100">
              {user.name}
            </p>
            <p className="truncate text-xs text-zinc-500">{user.email}</p>
          </div>
        </div>
        <div className="space-y-0.5 border-t border-white/[0.06] py-1">
          <button
            type="button"
            role="menuitem"
            tabIndex={open ? 0 : -1}
            onClick={run(onProfile)}
            className={itemClass}
          >
            <UserRound className="h-4 w-4 text-zinc-500" strokeWidth={1.75} />
            Profile
          </button>
          <button
            type="button"
            role="menuitem"
            tabIndex={open ? 0 : -1}
            onClick={run(onSettings)}
            className={itemClass}
          >
            <Settings className="h-4 w-4 text-zinc-500" strokeWidth={1.75} />
            Settings
          </button>
        </div>
        <div className="border-t border-white/[0.06] pt-1">
          <button
            type="button"
            role="menuitem"
            tabIndex={open ? 0 : -1}
            onClick={run(onLogout)}
            className={cx(
              itemClass,
              "text-red-400 hover:bg-red-500/10 hover:text-red-300",
            )}
          >
            <LogOut className="h-4 w-4" strokeWidth={1.75} />
            Log out
          </button>
        </div>
      </div>

      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label={`Account menu for ${user.name}`}
        className={cx(
          "flex w-full items-center gap-2.5 rounded-lg p-1.5 text-left transition-colors hover:bg-white/[0.04]",
          focusRing,
          open && "bg-white/[0.05]",
          collapsed && "md:mx-auto md:w-auto md:justify-center",
        )}
      >
        <Avatar user={user} />
        <div className={cx("min-w-0 flex-1", rail)}>
          <div className="flex items-center gap-1.5">
            <span className="truncate text-[13px] font-semibold text-zinc-100">
              {user.name}
            </span>
            {user.role && (
              <span className="shrink-0 rounded bg-[color:var(--sb-accent-soft)] px-1.5 py-px text-[10px] font-semibold text-[color:var(--sb-accent)]">
                {user.role}
              </span>
            )}
          </div>
          <p className="truncate text-xs text-zinc-500">{user.email}</p>
        </div>
        <ChevronsUpDown
          className={cx("h-4 w-4 shrink-0 text-zinc-500", rail)}
          strokeWidth={1.75}
        />
      </button>
    </div>
  );
}

function Avatar({ user }: { user: SidebarUser }) {
  const initials = user.name
    .split(" ")
    .map((p) => p[0])
    .join("")
    .slice(0, 2)
    .toUpperCase();

  return user.avatarUrl ? (
    <img
      src={user.avatarUrl}
      alt=""
      className="h-8 w-8 shrink-0 rounded-full object-cover ring-1 ring-white/10"
    />
  ) : (
    <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-zinc-700 text-[11px] font-semibold text-zinc-100 ring-1 ring-white/10">
      {initials}
    </span>
  );
}
// Component Source: https://codekk.dev/
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A free React admin dashboard sidebar built with TypeScript, Tailwind CSS, and Lucide icons. It works as the main navigation for admin panels, SaaS dashboards, and internal tools. Links are grouped into titled sections, and any link can become a dropdown with nested sub-links. Open groups expand with a smooth height animation, and the active sub-link is marked with an accent line on the guide rail. If the active link is inside a group, that group opens automatically. Badges show either a counter (like unread messages) or a text pill like "New" or "Soon", and any link can be disabled. On desktop, the sidebar collapses into a slim icon rail with the button next to the logo. In the rail, hovering a link shows a tooltip, and clicking a dropdown group opens a flyout menu beside it. Hovering the logo turns it into an expand button. On mobile, the sidebar becomes a slide-in drawer with a dimmed backdrop that locks page scroll and closes with Escape, a backdrop tap, or after picking a link. The search button supports a Ctrl K / ⌘K shortcut and shows the right key for the user's OS. The user menu at the bottom shows the name, email, and role, with Profile, Settings, and Log out actions. It falls back to initials when there is no avatar image. A single accentColor prop takes any hex color and themes the active states, focus rings, badges, and logo. Collapsed state and the active link can be controlled or left to the component. The onNavigate callback lets you plug in React Router or Next.js routing, and a findSidebarLink helper builds breadcrumbs from the same data. All links live in one data file, so you can edit the menu without touching the component. Animations are turned off when prefers-reduced-motion is enabled. It works in Vite, Create React App, and Next.js.

Install

terminal
npm
npm install lucide-react

Dependencies

lucide-react
Added Oct 4, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation

Similar Components

More from the same category