Web InnoventixFreeCode

Nested Sidebar

Original · free

sidebar with expandable nested items

byWeb InnoventixReact + Tailwind
sidenestedsidebars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/side-nested.json
side-nested.tsx
"use client";

import { useCallback, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactElement } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ */
/* Types                                                              */
/* ------------------------------------------------------------------ */

type IconName = "overview" | "analytics" | "campaigns" | "audiences" | "settings";
type BadgeTone = "emerald" | "amber" | "indigo" | "sky";

type Metric = { label: string; value: string; delta?: string; trend?: "up" | "down" };

type NodeBadge = { live?: boolean; text?: string; tone?: BadgeTone };

type TreeNode = {
  id: string;
  label: string;
  desc: string;
  icon?: IconName;
  badge?: NodeBadge;
  updated?: string;
  metrics?: Metric[];
  children?: TreeNode[];
};

type FlatItem = {
  id: string;
  level: number;
  parentId: string | null;
  hasChildren: boolean;
  isExpanded: boolean;
};

/* ------------------------------------------------------------------ */
/* Data                                                               */
/* ------------------------------------------------------------------ */

const TREE: TreeNode[] = [
  {
    id: "overview",
    label: "Overview",
    icon: "overview",
    desc: "One pane for everything that matters — funnel health, live sessions, and the numbers your team checks first thing every morning.",
    updated: "Updated 4 minutes ago",
    metrics: [
      { label: "Sessions today", value: "18,204", delta: "+12.4%", trend: "up" },
      { label: "Conversion rate", value: "3.8%", delta: "+0.6pt", trend: "up" },
      { label: "Active right now", value: "342", delta: "live" },
    ],
  },
  {
    id: "analytics",
    label: "Analytics",
    icon: "analytics",
    desc: "Trends across traffic, conversions, and engagement — sliced by any dimension you care about.",
    updated: "Updated 11 minutes ago",
    metrics: [
      { label: "Pageviews (30d)", value: "1.24M", delta: "+8.1%", trend: "up" },
      { label: "Avg. engaged time", value: "2m 41s", delta: "+5%", trend: "up" },
      { label: "Bounce rate", value: "41.2%", delta: "-3.0pt", trend: "up" },
    ],
    children: [
      {
        id: "traffic",
        label: "Traffic sources",
        desc: "Where visitors come from — organic, referral, paid, and direct — with quality scored per channel.",
        updated: "Updated 11 minutes ago",
        metrics: [
          { label: "Organic", value: "46%", delta: "+4pt", trend: "up" },
          { label: "Referral", value: "23%", delta: "-1pt", trend: "down" },
          { label: "Paid", value: "18%", delta: "+2pt", trend: "up" },
        ],
      },
      {
        id: "conversions",
        label: "Conversions",
        desc: "Every goal completion, from newsletter sign-ups to checkout, attributed back to its first touch.",
        updated: "Updated 6 minutes ago",
        metrics: [
          { label: "Goals (30d)", value: "1,940", delta: "+9%", trend: "up" },
          { label: "Revenue", value: "$84.2k", delta: "+14%", trend: "up" },
          { label: "Cost / conv.", value: "$6.10", delta: "-8%", trend: "up" },
        ],
      },
      {
        id: "realtime",
        label: "Real-time",
        badge: { live: true },
        desc: "Sessions happening right now, refreshed every few seconds, with a live view of the busiest pages.",
        updated: "Streaming",
        metrics: [
          { label: "Active users", value: "342", delta: "live" },
          { label: "Top page", value: "/pricing" },
          { label: "Views / min", value: "58", delta: "+6", trend: "up" },
        ],
      },
    ],
  },
  {
    id: "campaigns",
    label: "Campaigns",
    icon: "campaigns",
    desc: "Plan, launch, and measure outbound across every channel from one place.",
    updated: "Updated yesterday",
    metrics: [
      { label: "Active", value: "14" },
      { label: "Spend (30d)", value: "$62.1k", delta: "+7%", trend: "up" },
      { label: "Blended ROAS", value: "3.4x", delta: "+0.3", trend: "up" },
    ],
    children: [
      {
        id: "email",
        label: "Email",
        desc: "Broadcast and lifecycle email — open rates, clicks, and revenue per send.",
        updated: "Updated 2 hours ago",
        metrics: [
          { label: "Open rate", value: "38.2%", delta: "+1.4pt", trend: "up" },
          { label: "Click rate", value: "4.9%", delta: "+0.2pt", trend: "up" },
          { label: "Revenue", value: "$21.7k", delta: "+11%", trend: "up" },
        ],
        children: [
          {
            id: "newsletters",
            label: "Newsletters",
            desc: "One-off broadcasts to your full list or a saved segment.",
            updated: "Last sent 3 days ago",
            metrics: [
              { label: "Sent", value: "128k" },
              { label: "Opens", value: "48.9k" },
              { label: "Unsub rate", value: "0.12%", delta: "-0.03pt", trend: "up" },
            ],
          },
          {
            id: "drip",
            label: "Drip sequences",
            badge: { text: "3", tone: "indigo" },
            desc: "Automated sequences that trigger on signup, purchase, or a stretch of inactivity.",
            updated: "Updated 40 minutes ago",
            metrics: [
              { label: "Sequences", value: "3" },
              { label: "In progress", value: "6,412" },
              { label: "Completion", value: "71%", delta: "+5pt", trend: "up" },
            ],
          },
        ],
      },
      {
        id: "social",
        label: "Social ads",
        desc: "Paid social across Meta, LinkedIn, and X with unified spend and ROAS.",
        updated: "Updated 1 hour ago",
        metrics: [
          { label: "Impressions", value: "2.4M" },
          { label: "CTR", value: "1.8%", delta: "+0.2pt", trend: "up" },
          { label: "ROAS", value: "2.9x", delta: "+0.1", trend: "up" },
        ],
      },
      {
        id: "searchads",
        label: "Search ads",
        badge: { text: "Beta", tone: "emerald" },
        desc: "Search and shopping campaigns synced directly from your ad accounts.",
        updated: "Updated 1 hour ago",
        metrics: [
          { label: "Clicks", value: "41.3k" },
          { label: "CPC", value: "$1.12", delta: "-4%", trend: "up" },
          { label: "Conversions", value: "1,204", delta: "+6%", trend: "up" },
        ],
      },
    ],
  },
  {
    id: "audiences",
    label: "Audiences",
    icon: "audiences",
    desc: "Build, save, and sync the exact groups of people you want to reach.",
    updated: "Updated 3 hours ago",
    metrics: [
      { label: "Saved segments", value: "22" },
      { label: "Synced", value: "9" },
      { label: "Total reach", value: "512k", delta: "+3%", trend: "up" },
    ],
    children: [
      {
        id: "segments",
        label: "Segments",
        desc: "Rule-based groups that update themselves automatically as behavior changes.",
        updated: "Updated 3 hours ago",
        metrics: [
          { label: "Active rules", value: "34" },
          { label: "Live segments", value: "22" },
          { label: "Avg. size", value: "23k" },
        ],
      },
      {
        id: "cohorts",
        label: "Cohorts",
        desc: "Fixed groups defined by when users joined — the backbone of retention analysis.",
        updated: "Updated 3 hours ago",
        metrics: [
          { label: "Cohorts", value: "8" },
          { label: "Wk-4 retention", value: "38%" },
          { label: "Window", value: "6 mo" },
        ],
      },
    ],
  },
  {
    id: "settings",
    label: "Settings",
    icon: "settings",
    desc: "Workspace configuration, access control, and billing.",
    updated: "Updated last week",
    children: [
      {
        id: "team",
        label: "Team & roles",
        desc: "Invite teammates and control who can view, edit, or publish.",
        updated: "Updated last week",
        metrics: [
          { label: "Members", value: "11" },
          { label: "Pending invites", value: "2" },
          { label: "Roles", value: "4" },
        ],
      },
      {
        id: "integrations",
        label: "Integrations",
        badge: { text: "12", tone: "sky" },
        desc: "Connect your data warehouse, CRM, and ad platforms.",
        updated: "Last sync 3 minutes ago",
        metrics: [
          { label: "Connected", value: "12" },
          { label: "Warehouse", value: "Snowflake" },
          { label: "Last sync", value: "3m ago" },
        ],
      },
      {
        id: "billing",
        label: "Billing",
        badge: { text: "Due", tone: "amber" },
        desc: "Plan, usage, invoices, and payment method.",
        updated: "Invoice due in 5 days",
        metrics: [
          { label: "Plan", value: "Pro" },
          { label: "Seats", value: "11 / 15" },
          { label: "Next invoice", value: "$490" },
        ],
      },
    ],
  },
];

/* ------------------------------------------------------------------ */
/* Pure helpers                                                       */
/* ------------------------------------------------------------------ */

function flatten(
  nodes: TreeNode[],
  expanded: Set<string>,
  level = 0,
  parentId: string | null = null,
  out: FlatItem[] = [],
): FlatItem[] {
  for (const n of nodes) {
    const hasChildren = !!(n.children && n.children.length);
    const isExpanded = hasChildren && expanded.has(n.id);
    out.push({ id: n.id, level, parentId, hasChildren, isExpanded });
    if (isExpanded && n.children) flatten(n.children, expanded, level + 1, n.id, out);
  }
  return out;
}

function collectParentIds(nodes: TreeNode[], out: string[] = []): string[] {
  for (const n of nodes) {
    if (n.children && n.children.length) {
      out.push(n.id);
      collectParentIds(n.children, out);
    }
  }
  return out;
}

function findNode(nodes: TreeNode[], id: string): TreeNode | null {
  for (const n of nodes) {
    if (n.id === id) return n;
    if (n.children) {
      const f = findNode(n.children, id);
      if (f) return f;
    }
  }
  return null;
}

function findPath(nodes: TreeNode[], id: string, trail: TreeNode[] = []): TreeNode[] | null {
  for (const n of nodes) {
    const t = [...trail, n];
    if (n.id === id) return t;
    if (n.children) {
      const f = findPath(n.children, id, t);
      if (f) return f;
    }
  }
  return null;
}

const ALL_PARENTS = collectParentIds(TREE);

/* ------------------------------------------------------------------ */
/* Icons (inline SVG)                                                 */
/* ------------------------------------------------------------------ */

function NodeIcon({ name, className }: { name: IconName; className?: string }) {
  const common = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.8,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className,
    "aria-hidden": true,
  };
  switch (name) {
    case "overview":
      return (
        <svg {...common}>
          <rect x="3" y="3" width="7" height="7" rx="1.6" />
          <rect x="14" y="3" width="7" height="7" rx="1.6" />
          <rect x="3" y="14" width="7" height="7" rx="1.6" />
          <rect x="14" y="14" width="7" height="7" rx="1.6" />
        </svg>
      );
    case "analytics":
      return (
        <svg {...common}>
          <line x1="5" y1="21" x2="5" y2="12" />
          <line x1="12" y1="21" x2="12" y2="4" />
          <line x1="19" y1="21" x2="19" y2="9" />
        </svg>
      );
    case "campaigns":
      return (
        <svg {...common}>
          <path d="M3 11v2a1 1 0 0 0 1 1h2l4.2 3.6a.8.8 0 0 0 1.3-.6V6.4a.8.8 0 0 0-1.3-.6L6 10H4a1 1 0 0 0-1 1Z" />
          <path d="M15.5 8.5a5 5 0 0 1 0 7" />
          <path d="M18.5 5.5a9 9 0 0 1 0 13" />
        </svg>
      );
    case "audiences":
      return (
        <svg {...common}>
          <circle cx="9" cy="8" r="3.2" />
          <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
          <path d="M16 5.4a3.2 3.2 0 0 1 0 6.1" />
          <path d="M17.4 14.6a5.4 5.4 0 0 1 3.9 5.4" />
        </svg>
      );
    case "settings":
      return (
        <svg {...common}>
          <circle cx="12" cy="12" r="3.2" />
          <path d="M12 2.8v2.6M12 18.6v2.6M4.5 7.4l2.2 1.3M17.3 15.3l2.2 1.3M4.5 16.6l2.2-1.3M17.3 8.7l2.2-1.3" />
        </svg>
      );
  }
}

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden
    >
      <path d="m9 6 6 6-6 6" />
    </svg>
  );
}

function StarIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
      <path d="M12 2.2 14.4 9.2 21.6 12 14.4 14.8 12 21.8 9.6 14.8 2.4 12 9.6 9.2Z" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Style tokens                                                       */
/* ------------------------------------------------------------------ */

const BADGE_TONE: Record<BadgeTone, string> = {
  emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  amber: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  sky: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
};

function deltaClass(trend?: "up" | "down"): string {
  if (trend === "up") return "text-emerald-600 dark:text-emerald-400";
  if (trend === "down") return "text-rose-600 dark:text-rose-400";
  return "text-slate-500 dark:text-slate-400";
}

function LiveDot() {
  return (
    <span className="relative flex h-1.5 w-1.5">
      <span className="sidenested-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
      <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Component                                                          */
/* ------------------------------------------------------------------ */

export default function SideNested() {
  const reduce = useReducedMotion();

  const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["analytics"]));
  const [selected, setSelected] = useState<string>("overview");
  const [focusedId, setFocusedId] = useState<string>("overview");

  const itemRefs = useRef<Map<string, HTMLLIElement>>(new Map());
  const setItemRef = useCallback((id: string, el: HTMLLIElement | null) => {
    const m = itemRefs.current;
    if (el) m.set(id, el);
    else m.delete(id);
  }, []);

  const flat = useMemo(() => flatten(TREE, expanded), [expanded]);

  const selectedNode = useMemo(() => findNode(TREE, selected), [selected]);
  const path = useMemo(() => findPath(TREE, selected) ?? [], [selected]);
  const rootIcon = path[0]?.icon;

  const moveFocus = useCallback((id: string) => {
    setFocusedId(id);
    const el = itemRefs.current.get(id);
    if (el) el.focus();
  }, []);

  const toggle = useCallback((id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const expandNode = useCallback((id: string) => {
    setExpanded((prev) => {
      if (prev.has(id)) return prev;
      const next = new Set(prev);
      next.add(id);
      return next;
    });
  }, []);

  const collapseNode = useCallback((id: string) => {
    setExpanded((prev) => {
      if (!prev.has(id)) return prev;
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, []);

  const activate = useCallback(
    (item: FlatItem) => {
      setFocusedId(item.id);
      const el = itemRefs.current.get(item.id);
      if (el) el.focus();
      if (item.hasChildren) toggle(item.id);
      setSelected(item.id);
    },
    [toggle],
  );

  const expandAll = useCallback(() => setExpanded(new Set(ALL_PARENTS)), []);
  const collapseAll = useCallback(() => {
    const root = findPath(TREE, focusedId)?.[0]?.id ?? TREE[0].id;
    setExpanded(new Set());
    setFocusedId(root);
  }, [focusedId]);

  const onTreeKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLUListElement>) => {
      const idx = flat.findIndex((f) => f.id === focusedId);
      if (idx === -1) return;
      const cur = flat[idx];

      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          if (idx < flat.length - 1) moveFocus(flat[idx + 1].id);
          break;
        case "ArrowUp":
          e.preventDefault();
          if (idx > 0) moveFocus(flat[idx - 1].id);
          break;
        case "Home":
          e.preventDefault();
          moveFocus(flat[0].id);
          break;
        case "End":
          e.preventDefault();
          moveFocus(flat[flat.length - 1].id);
          break;
        case "ArrowRight":
          e.preventDefault();
          if (cur.hasChildren) {
            if (!cur.isExpanded) expandNode(cur.id);
            else if (idx < flat.length - 1) moveFocus(flat[idx + 1].id);
          }
          break;
        case "ArrowLeft":
          e.preventDefault();
          if (cur.hasChildren && cur.isExpanded) collapseNode(cur.id);
          else if (cur.parentId) moveFocus(cur.parentId);
          break;
        case "Enter":
        case " ":
          e.preventDefault();
          if (cur.hasChildren) toggle(cur.id);
          setSelected(cur.id);
          break;
        case "*": {
          e.preventDefault();
          const sibs = flat.filter((f) => f.parentId === cur.parentId && f.hasChildren);
          setExpanded((prev) => {
            const next = new Set(prev);
            for (const s of sibs) next.add(s.id);
            return next;
          });
          break;
        }
        default:
          break;
      }
    },
    [flat, focusedId, moveFocus, expandNode, collapseNode, toggle],
  );

  /* ------- recursive renderer ------- */
  const renderNode = (node: TreeNode, level: number): ReactElement => {
    const hasChildren = !!(node.children && node.children.length);
    const isExpanded = hasChildren && expanded.has(node.id);
    const isSelected = selected === node.id;

    return (
      <li
        key={node.id}
        role="treeitem"
        aria-label={node.label}
        aria-level={level + 1}
        aria-selected={isSelected}
        aria-expanded={hasChildren ? isExpanded : undefined}
        tabIndex={focusedId === node.id ? 0 : -1}
        ref={(el) => setItemRef(node.id, el)}
        onFocus={() => setFocusedId(node.id)}
        onClick={(e) => {
          e.stopPropagation();
          activate({ id: node.id, level, parentId: null, hasChildren, isExpanded });
        }}
        className="relative rounded-lg outline-none focus:outline-none focus-visible:[&>div]:ring-2 focus-visible:[&>div]:ring-inset focus-visible:[&>div]:ring-indigo-500 dark:focus-visible:[&>div]:ring-indigo-400"
      >
        <div
          className={[
            "group/row relative flex select-none items-center gap-2 rounded-lg py-2 pr-2.5 text-[13.5px] transition-colors",
            level === 0 ? "pl-2.5" : "pl-1.5",
            isSelected
              ? "bg-indigo-50 font-semibold text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-200"
              : "font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800/70 dark:hover:text-white",
          ].join(" ")}
        >
          {isSelected && (
            <span className="absolute -left-1.5 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
          )}

          {hasChildren ? (
            <motion.span
              className={[
                "grid h-5 w-5 shrink-0 place-items-center rounded-md",
                isSelected
                  ? "text-indigo-500 dark:text-indigo-300"
                  : "text-slate-400 group-hover/row:text-slate-600 dark:text-slate-500 dark:group-hover/row:text-slate-300",
              ].join(" ")}
              animate={{ rotate: isExpanded ? 90 : 0 }}
              transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
            >
              <ChevronIcon className="h-3.5 w-3.5" />
            </motion.span>
          ) : (
            <span className="grid h-5 w-5 shrink-0 place-items-center" aria-hidden>
              <span className="h-1 w-1 rounded-full bg-slate-300 dark:bg-slate-600" />
            </span>
          )}

          {level === 0 && node.icon && (
            <NodeIcon
              name={node.icon}
              className={[
                "h-[18px] w-[18px] shrink-0",
                isSelected
                  ? "text-indigo-500 dark:text-indigo-300"
                  : "text-slate-400 group-hover/row:text-slate-600 dark:text-slate-500 dark:group-hover/row:text-slate-300",
              ].join(" ")}
            />
          )}

          <span className="min-w-0 flex-1 truncate">{node.label}</span>

          {node.badge?.live && (
            <span className="shrink-0" aria-label="live">
              <LiveDot />
            </span>
          )}
          {node.badge?.text && (
            <span
              className={[
                "shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none tabular-nums",
                BADGE_TONE[node.badge.tone ?? "indigo"],
              ].join(" ")}
            >
              {node.badge.text}
            </span>
          )}
        </div>

        {hasChildren && (
          <AnimatePresence initial={false}>
            {isExpanded && (
              <motion.ul
                key={`grp-${node.id}`}
                role="group"
                initial={reduce ? false : { height: 0, opacity: 0 }}
                animate={reduce ? {} : { height: "auto", opacity: 1 }}
                exit={reduce ? {} : { height: 0, opacity: 0 }}
                transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 0.61, 0.36, 1] }}
                style={{ overflow: "hidden" }}
                className="ml-[15px] space-y-0.5 border-l border-slate-200 py-0.5 pl-2 dark:border-slate-700/70"
              >
                {node.children!.map((c) => renderNode(c, level + 1))}
              </motion.ul>
            )}
          </AnimatePresence>
        )}
      </li>
    );
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-100 px-4 py-16 text-slate-900 sm:px-6 md:py-24 dark:bg-slate-950 dark:text-white">
      <style>{`
        @keyframes sidenested-fade-in {
          from { opacity: 0; transform: translateY(14px); }
          to   { opacity: 1; transform: none; }
        }
        @keyframes sidenested-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50%      { opacity: 0.3; transform: scale(1.9); }
        }
        .sidenested-fade-in { animation: sidenested-fade-in .55s cubic-bezier(.22,.61,.36,1) both; }
        .sidenested-pulse   { animation: sidenested-pulse 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sidenested-fade-in, .sidenested-pulse { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-70"
        style={{
          backgroundImage:
            "radial-gradient(60% 55% at 12% 0%, rgba(99,102,241,0.14), transparent 60%), radial-gradient(50% 50% at 100% 100%, rgba(16,185,129,0.10), transparent 60%)",
        }}
      />

      <div className="sidenested-fade-in relative mx-auto max-w-5xl">
        <header className="mb-8 max-w-xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-400">
            <StarIcon className="h-3 w-3 text-indigo-500 dark:text-indigo-400" />
            Northstar UI
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
            Nested workspace{" "}
            <span className="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent dark:from-indigo-400 dark:to-violet-400">
              navigation
            </span>
          </h2>
          <p className="mt-3 text-[15px] leading-relaxed text-slate-500 dark:text-slate-400">
            A keyboard-accessible tree sidebar. Expand branches, drill into any level, and drive the
            panel beside it — arrows to move, <kbd className="rounded bg-slate-200 px-1 text-xs dark:bg-slate-800">→</kbd>{" "}
            /<kbd className="rounded bg-slate-200 px-1 text-xs dark:bg-slate-800">←</kbd> to open and close.
          </p>
        </header>

        <div className="flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_20px_60px_-30px_rgba(15,23,42,0.5)] md:h-[600px] md:flex-row dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_30px_80px_-40px_rgba(0,0,0,0.9)]">
          {/* ---------------- SIDEBAR ---------------- */}
          <aside className="flex w-full shrink-0 flex-col border-b border-slate-200 bg-slate-50/60 md:w-72 md:border-b-0 md:border-r dark:border-slate-800 dark:bg-slate-950/40">
            {/* brand */}
            <div className="flex items-center gap-3 px-4 py-4">
              <span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/30">
                <StarIcon className="h-[18px] w-[18px]" />
              </span>
              <div className="min-w-0 leading-tight">
                <p className="truncate text-sm font-bold text-slate-900 dark:text-white">Northstar</p>
                <p className="truncate text-[11px] text-slate-500 dark:text-slate-400">Growth workspace</p>
              </div>
            </div>

            {/* controls */}
            <div className="flex items-center justify-between px-4 pb-2 pt-1">
              <span
                id="sidenested-tree-label"
                className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500"
              >
                Workspace
              </span>
              <div className="flex items-center gap-1">
                <button
                  type="button"
                  onClick={expandAll}
                  className="rounded-md px-1.5 py-1 text-[11px] font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
                >
                  Expand all
                </button>
                <button
                  type="button"
                  onClick={collapseAll}
                  className="rounded-md px-1.5 py-1 text-[11px] font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
                >
                  Collapse
                </button>
              </div>
            </div>

            {/* tree */}
            <nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label="Workspace navigation">
              <p id="sidenested-help" className="sr-only">
                Use the up and down arrow keys to move between items, right arrow to expand, left arrow
                to collapse, Enter to select, and asterisk to expand all items at the current level.
              </p>
              <ul
                role="tree"
                aria-labelledby="sidenested-tree-label"
                aria-describedby="sidenested-help"
                onKeyDown={onTreeKeyDown}
                className="space-y-0.5"
              >
                {TREE.map((n) => renderNode(n, 0))}
              </ul>
            </nav>

            {/* footer / user */}
            <div className="mt-auto flex items-center gap-3 border-t border-slate-200 px-4 py-3 dark:border-slate-800">
              <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-amber-400 to-rose-500 text-[11px] font-bold text-white">
                MO
              </span>
              <div className="min-w-0 flex-1 leading-tight">
                <p className="truncate text-[13px] font-semibold text-slate-800 dark:text-slate-100">
                  Maya Okonkwo
                </p>
                <p className="truncate text-[11px] text-slate-500 dark:text-slate-400">Growth lead</p>
              </div>
              <span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
                Pro
              </span>
            </div>
          </aside>

          {/* ---------------- CONTENT PANEL ---------------- */}
          <main className="min-w-0 flex-1 overflow-y-auto p-6 md:p-8">
            {selectedNode && (
              <div key={selectedNode.id} className="sidenested-fade-in">
                {/* breadcrumb */}
                <nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-[12px]">
                  {path.map((p, i) => (
                    <span key={p.id} className="flex items-center gap-1.5">
                      {i > 0 && (
                        <ChevronIcon className="h-3 w-3 text-slate-300 dark:text-slate-600" />
                      )}
                      <span
                        className={
                          i === path.length - 1
                            ? "font-semibold text-slate-700 dark:text-slate-200"
                            : "text-slate-400 dark:text-slate-500"
                        }
                      >
                        {p.label}
                      </span>
                    </span>
                  ))}
                </nav>

                <div className="flex items-start gap-4">
                  {rootIcon && (
                    <span className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
                      <NodeIcon name={rootIcon} className="h-6 w-6" />
                    </span>
                  )}
                  <div className="min-w-0 flex-1">
                    <div className="flex flex-wrap items-center gap-2.5">
                      <h3 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
                        {selectedNode.label}
                      </h3>
                      {selectedNode.badge?.live && (
                        <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
                          <LiveDot />
                          Live
                        </span>
                      )}
                      {selectedNode.badge?.text && (
                        <span
                          className={[
                            "rounded-full px-2 py-0.5 text-[11px] font-semibold",
                            BADGE_TONE[selectedNode.badge.tone ?? "indigo"],
                          ].join(" ")}
                        >
                          {selectedNode.badge.text}
                        </span>
                      )}
                    </div>
                    {selectedNode.updated && (
                      <p className="mt-1 text-[12px] text-slate-400 dark:text-slate-500">
                        {selectedNode.updated}
                      </p>
                    )}
                  </div>
                </div>

                <p className="mt-4 max-w-prose text-[15px] leading-relaxed text-slate-600 dark:text-slate-300">
                  {selectedNode.desc}
                </p>

                {selectedNode.metrics && (
                  <div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-3">
                    {selectedNode.metrics.map((m) => (
                      <div
                        key={m.label}
                        className="rounded-xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/40"
                      >
                        <p className="text-[12px] text-slate-500 dark:text-slate-400">{m.label}</p>
                        <p className="mt-1 text-xl font-bold tabular-nums text-slate-900 dark:text-white">
                          {m.value}
                        </p>
                        {m.delta && (
                          <p className={["mt-1 text-[12px] font-medium tabular-nums", deltaClass(m.trend)].join(" ")}>
                            {m.trend === "up" ? "↑ " : m.trend === "down" ? "↓ " : "• "}
                            {m.delta}
                          </p>
                        )}
                      </div>
                    ))}
                  </div>
                )}

                <div className="mt-7 flex flex-wrap gap-3">
                  <button
                    type="button"
                    className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                  >
                    Open {selectedNode.label}
                    <ChevronIcon className="h-4 w-4" />
                  </button>
                  <button
                    type="button"
                    className="inline-flex items-center rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
                  >
                    Share view
                  </button>
                </div>
              </div>
            )}
          </main>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →