Web InnoventixFreeCode

Mobile Sidebar

Original · free

mobile hamburger slide menu

byWeb InnoventixReact + Tailwind
sidemobilesidebars
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-mobile.json
side-mobile.tsx
"use client";

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

type IconName =
  | "home"
  | "work"
  | "studio"
  | "services"
  | "journal"
  | "contact"
  | "chevron"
  | "close"
  | "arrow"
  | "bell";

type Child = { id: string; label: string; href: string };

type Leaf = {
  id: string;
  label: string;
  href: string;
  icon: Exclude<IconName, "chevron" | "close" | "arrow" | "bell">;
  badge?: string;
};

type Group = Leaf & { children: Child[] };

type NavNode = Leaf | Group;

function isGroup(n: NavNode): n is Group {
  return "children" in n;
}

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

const NAV: NavNode[] = [
  { id: "home", label: "Home", href: "#home", icon: "home" },
  {
    id: "work",
    label: "Work",
    href: "#work",
    icon: "work",
    children: [
      { id: "work-cases", label: "Case studies", href: "#work/cases" },
      { id: "work-clients", label: "Selected clients", href: "#work/clients" },
      { id: "work-awards", label: "Awards & press", href: "#work/awards" },
    ],
  },
  {
    id: "studio",
    label: "Studio",
    href: "#studio",
    icon: "studio",
    children: [
      { id: "studio-approach", label: "Our approach", href: "#studio/approach" },
      { id: "studio-team", label: "The team", href: "#studio/team" },
      { id: "studio-careers", label: "Careers (3 open)", href: "#studio/careers" },
    ],
  },
  {
    id: "services",
    label: "Services",
    href: "#services",
    icon: "services",
    children: [
      { id: "svc-brand", label: "Brand identity", href: "#services/brand" },
      { id: "svc-web", label: "Web & product", href: "#services/web" },
      { id: "svc-motion", label: "Motion & film", href: "#services/motion" },
    ],
  },
  { id: "journal", label: "Journal", href: "#journal", icon: "journal", badge: "New" },
  { id: "contact", label: "Contact", href: "#contact", icon: "contact" },
];

const GROUP_BY_ID: Record<string, Group> = {};
const PARENT_OF: Record<string, string> = {};
for (const node of NAV) {
  if (isGroup(node)) {
    GROUP_BY_ID[node.id] = node;
    for (const child of node.children) PARENT_OF[child.id] = node.id;
  }
}

const paths: Record<IconName, ReactNode> = {
  home: (
    <>
      <path d="M3 10.75 12 3l9 7.75" />
      <path d="M5 9.5V20a1 1 0 0 0 1 1h4v-5.5h4V21h4a1 1 0 0 0 1-1V9.5" />
    </>
  ),
  work: (
    <>
      <path d="M3.5 8.5h17v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z" />
      <path d="M8.5 8.5V6.75A1.75 1.75 0 0 1 10.25 5h3.5A1.75 1.75 0 0 1 15.5 6.75V8.5" />
      <path d="M3.5 12.5h17" />
    </>
  ),
  studio: (
    <>
      <path d="M9 10.5a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5z" />
      <path d="M3.5 19.5a5.5 5.5 0 0 1 11 0" />
      <path d="M16.25 5.2a2.75 2.75 0 0 1 0 5.35" />
      <path d="M16 14.4a5.5 5.5 0 0 1 4.5 5.1" />
    </>
  ),
  services: (
    <>
      <path d="M12 3.5 3.5 8 12 12.5 20.5 8 12 3.5z" />
      <path d="M3.5 12 12 16.5 20.5 12" />
      <path d="M3.5 16 12 20.5 20.5 16" />
    </>
  ),
  journal: (
    <>
      <path d="M12 6.5C10.4 5.2 7.8 4.8 5 5.2V18c2.8-.4 5.4 0 7 1.3 1.6-1.3 4.2-1.7 7-1.3V5.2c-2.8-.4-5.4 0-7 1.3z" />
      <path d="M12 6.5V19" />
    </>
  ),
  contact: (
    <>
      <path d="M3.5 6.5h17v11h-17z" />
      <path d="m4 7 8 6 8-6" />
    </>
  ),
  chevron: <path d="m6 9.5 6 6 6-6" />,
  close: (
    <>
      <path d="m6.5 6.5 11 11" />
      <path d="m17.5 6.5-11 11" />
    </>
  ),
  arrow: (
    <>
      <path d="M5 12h13" />
      <path d="m12.5 6.5 5.5 5.5-5.5 5.5" />
    </>
  ),
  bell: (
    <>
      <path d="M6.5 9.75a5.5 5.5 0 0 1 11 0c0 4.5 2 5.75 2 5.75h-15s2-1.25 2-5.75z" />
      <path d="M10 19.25a2 2 0 0 0 4 0" />
    </>
  ),
};

function Icon({ name, className = "h-5 w-5" }: { name: IconName; className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      {paths[name]}
    </svg>
  );
}

function getTabbables(root: HTMLElement): HTMLElement[] {
  const nodes = root.querySelectorAll<HTMLElement>(
    'a[href], button:not([disabled]), [tabindex]',
  );
  return Array.from(nodes).filter((el) => {
    const ti = el.getAttribute("tabindex");
    if (ti !== null && Number(ti) < 0) return false;
    return el.offsetParent !== null || el === document.activeElement;
  });
}

export default function SideMobile() {
  const prefersReduced = useReducedMotion();
  const reduce = !!prefersReduced;

  const [open, setOpen] = useState(false);
  const [expanded, setExpanded] = useState<string[]>(["work"]);
  const [activeKey, setActiveKey] = useState<string>(NAV[0].id);

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const drawerRef = useRef<HTMLDivElement | null>(null);
  const nodeRefs = useRef<Record<string, HTMLElement | null>>({});
  const pendingFocus = useRef<string | null>(null);
  const wasOpen = useRef(false);

  const rawId = useId();
  const panelId = `sidemob-${rawId.replace(/[:]/g, "")}`;
  const titleId = `${panelId}-title`;

  const visibleKeys = useMemo(() => {
    const keys: string[] = [];
    for (const node of NAV) {
      keys.push(node.id);
      if (isGroup(node) && expanded.includes(node.id)) {
        for (const child of node.children) keys.push(child.id);
      }
    }
    return keys;
  }, [expanded]);

  const openMenu = useCallback(() => setOpen(true), []);
  const closeMenu = useCallback(() => setOpen(false), []);

  const toggleGroup = useCallback((id: string) => {
    setExpanded((prev) =>
      prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
    );
    setActiveKey(id);
  }, []);

  const focusKey = useCallback((key: string) => {
    setActiveKey(key);
    nodeRefs.current[key]?.focus();
  }, []);

  useEffect(() => {
    if (!open) return;
    const first = NAV[0].id;
    setActiveKey(first);
    const raf = requestAnimationFrame(() => {
      nodeRefs.current[first]?.focus();
    });
    return () => cancelAnimationFrame(raf);
  }, [open]);

  useEffect(() => {
    if (wasOpen.current && !open) triggerRef.current?.focus();
    wasOpen.current = open;
  }, [open]);

  useEffect(() => {
    const key = pendingFocus.current;
    if (key && nodeRefs.current[key]) {
      pendingFocus.current = null;
      setActiveKey(key);
      nodeRefs.current[key]?.focus();
    }
  }, [visibleKeys]);

  const onMenuKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLElement>) => {
      const keys = visibleKeys;
      const len = keys.length;
      const idx = keys.indexOf(activeKey);
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          focusKey(idx < 0 ? keys[0] : keys[(idx + 1) % len]);
          break;
        case "ArrowUp":
          e.preventDefault();
          focusKey(idx < 0 ? keys[len - 1] : keys[(idx - 1 + len) % len]);
          break;
        case "Home":
          e.preventDefault();
          focusKey(keys[0]);
          break;
        case "End":
          e.preventDefault();
          focusKey(keys[len - 1]);
          break;
        case "ArrowRight": {
          const group = GROUP_BY_ID[activeKey];
          if (group) {
            e.preventDefault();
            if (!expanded.includes(group.id)) {
              pendingFocus.current = group.children[0].id;
              setExpanded((prev) => [...prev, group.id]);
            } else {
              focusKey(group.children[0].id);
            }
          }
          break;
        }
        case "ArrowLeft": {
          e.preventDefault();
          const parentId = PARENT_OF[activeKey];
          if (parentId) {
            setExpanded((prev) => prev.filter((x) => x !== parentId));
            focusKey(parentId);
          } else if (GROUP_BY_ID[activeKey] && expanded.includes(activeKey)) {
            setExpanded((prev) => prev.filter((x) => x !== activeKey));
          }
          break;
        }
        default:
          break;
      }
    },
    [activeKey, expanded, focusKey, visibleKeys],
  );

  const onDrawerKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      if (e.key === "Escape") {
        e.preventDefault();
        closeMenu();
        return;
      }
      if (e.key !== "Tab") return;
      const root = drawerRef.current;
      if (!root) return;
      const tabbables = getTabbables(root);
      if (tabbables.length === 0) return;
      const first = tabbables[0];
      const last = tabbables[tabbables.length - 1];
      const active = document.activeElement;
      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    },
    [closeMenu],
  );

  const backdropVariants = { open: { opacity: 1 }, closed: { opacity: 0 } };
  const drawerVariants = reduce
    ? { open: { opacity: 1 }, closed: { opacity: 0 } }
    : { open: { x: 0 }, closed: { x: "-100%" } };

  const logoMark = (
    <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-[13px] font-bold text-white shadow-sm shadow-violet-500/30">
      F
    </span>
  );

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-4 py-16 text-slate-900 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100 sm:py-24">
      <style>{`
        @keyframes sidemob_fadeup {
          from { opacity: 0; transform: translateY(7px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes sidemob_pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.55; transform: scale(0.82); }
        }
        @keyframes sidemob_shimmer {
          0% { background-position: -160% 0; }
          100% { background-position: 260% 0; }
        }
        .sidemob-item { animation: sidemob_fadeup 0.42s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .sidemob-dot { animation: sidemob_pulse 1.8s ease-in-out infinite; }
        .sidemob-shine {
          background: linear-gradient(100deg, transparent 22%, rgba(255,255,255,0.4) 50%, transparent 78%);
          background-size: 220% 100%;
          animation: sidemob_shimmer 3.4s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .sidemob-item, .sidemob-dot, .sidemob-shine { animation: none !important; }
          .sidemob-item { opacity: 1 !important; transform: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/10"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/10"
      />

      <div className="relative mx-auto flex max-w-md flex-col items-center gap-6">
        <div className="text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Mobile navigation
          </p>
          <p className="mt-1.5 text-sm text-slate-500 dark:text-zinc-400">
            Tap the menu to slide the drawer open
          </p>
        </div>

        <div className="relative flex h-[660px] w-full max-w-[380px] flex-col overflow-hidden rounded-[2.25rem] border border-zinc-200/80 bg-white shadow-2xl shadow-slate-900/10 ring-1 ring-black/[0.04] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/50 dark:ring-white/[0.06]">
          {/* Status bar */}
          <div className="flex shrink-0 items-center justify-between px-6 pt-3.5 pb-1 text-[11px] font-semibold text-zinc-800 dark:text-zinc-200">
            <span>9:41</span>
            <div className="flex items-center gap-1.5 text-zinc-700 dark:text-zinc-300">
              <svg width="17" height="11" viewBox="0 0 17 11" aria-hidden="true" className="fill-current">
                <rect x="0" y="7" width="3" height="4" rx="1" />
                <rect x="4.5" y="5" width="3" height="6" rx="1" />
                <rect x="9" y="2.5" width="3" height="8.5" rx="1" />
                <rect x="13.5" y="0" width="3" height="11" rx="1" />
              </svg>
              <svg
                width="16"
                height="11"
                viewBox="0 0 16 11"
                aria-hidden="true"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.4"
                strokeLinecap="round"
              >
                <path d="M1 3.5a10 10 0 0 1 14 0" />
                <path d="M3.5 6a6.5 6.5 0 0 1 9 0" />
                <path d="M6 8.4a3 3 0 0 1 4 0" />
              </svg>
              <svg width="24" height="12" viewBox="0 0 24 12" aria-hidden="true">
                <rect x="0.5" y="0.5" width="20" height="11" rx="3" className="stroke-current" strokeOpacity="0.5" fill="none" />
                <rect x="2.5" y="2.5" width="14" height="7" rx="1.5" className="fill-current" />
                <rect x="22" y="4" width="1.5" height="4" rx="0.75" className="fill-current" fillOpacity="0.5" />
              </svg>
            </div>
          </div>

          {/* App bar */}
          <header className="relative z-[1] flex shrink-0 items-center gap-1 border-b border-zinc-100 px-2 py-2.5 dark:border-zinc-800">
            <button
              ref={triggerRef}
              type="button"
              onClick={() => (open ? closeMenu() : openMenu())}
              aria-haspopup="dialog"
              aria-expanded={open}
              aria-controls={panelId}
              aria-label={open ? "Close menu" : "Open menu"}
              className="relative inline-flex h-10 w-10 items-center justify-center rounded-xl text-zinc-700 transition-colors hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
            >
              <span aria-hidden="true" className="relative block h-4 w-5">
                <span
                  className={cx(
                    "absolute left-0 top-0 h-0.5 w-5 rounded-full bg-current transition-all duration-300",
                    open && "top-1/2 -translate-y-1/2 rotate-45",
                  )}
                />
                <span
                  className={cx(
                    "absolute left-0 top-1/2 h-0.5 w-5 -translate-y-1/2 rounded-full bg-current transition-all duration-300",
                    open ? "opacity-0" : "opacity-100",
                  )}
                />
                <span
                  className={cx(
                    "absolute bottom-0 left-0 h-0.5 w-5 rounded-full bg-current transition-all duration-300",
                    open && "bottom-1/2 translate-y-1/2 -rotate-45",
                  )}
                />
              </span>
            </button>

            <span className="flex-1 text-center text-[15px] font-semibold tracking-tight text-zinc-900 dark:text-white">
              Fathom &amp; Field
            </span>

            <button
              type="button"
              tabIndex={open ? -1 : 0}
              aria-label="Notifications"
              className="relative inline-flex h-10 w-10 items-center justify-center rounded-xl text-zinc-600 transition-colors hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
            >
              <Icon name="bell" className="h-5 w-5" />
              <span
                aria-hidden="true"
                className="absolute right-2 top-2 h-2 w-2 rounded-full bg-rose-500 ring-2 ring-white dark:ring-zinc-900"
              />
            </button>
          </header>

          {/* Page content (behind the drawer) */}
          <main aria-hidden={open} className="min-h-0 flex-1 overflow-y-auto">
            <div className="space-y-5 px-5 pb-8 pt-5">
              <section>
                <p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
                  Design studio
                </p>
                <h1 className="mt-2 text-[26px] font-semibold leading-[1.15] tracking-tight text-zinc-900 dark:text-white">
                  Calmer products, made with care.
                </h1>
                <p className="mt-2.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
                  We help teams ship interfaces that feel quiet, fast, and genuinely
                  human — from the first rough sketch to launch day.
                </p>
                <button
                  type="button"
                  tabIndex={open ? -1 : 0}
                  className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 dark:focus-visible:ring-offset-zinc-900"
                >
                  See our work
                  <Icon name="arrow" className="h-4 w-4" />
                </button>
              </section>

              <section className="grid grid-cols-2 gap-3">
                <div className="rounded-2xl border border-zinc-100 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/30">
                  <p className="text-xl font-semibold text-zinc-900 dark:text-white">12 yrs</p>
                  <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">in practice</p>
                </div>
                <div className="rounded-2xl border border-zinc-100 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-800/30">
                  <p className="text-xl font-semibold text-zinc-900 dark:text-white">60+</p>
                  <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">products shipped</p>
                </div>
              </section>

              <section className="rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-800/30">
                <p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
                  Featured case study
                </p>
                <h2 className="mt-1.5 text-[15px] font-semibold text-zinc-900 dark:text-white">
                  Rebuilding trust for Kestrel Bank
                </h2>
                <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
                  A ground-up mobile redesign that cut support tickets by 34% in three
                  months.
                </p>
              </section>

              <section>
                <h2 className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
                  From the journal
                </h2>
                <ul className="mt-2 space-y-2.5">
                  <li className="flex items-start gap-3">
                    <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500" />
                    <div>
                      <p className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
                        Designing interfaces that stay out of the way
                      </p>
                      <p className="text-xs text-zinc-500 dark:text-zinc-400">6 min read · Jul 2026</p>
                    </div>
                  </li>
                  <li className="flex items-start gap-3">
                    <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500" />
                    <div>
                      <p className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
                        The quiet power of a good empty state
                      </p>
                      <p className="text-xs text-zinc-500 dark:text-zinc-400">4 min read · Jun 2026</p>
                    </div>
                  </li>
                </ul>
              </section>

              <p className="pt-1 text-center text-xs text-zinc-400 dark:text-zinc-500">
                Tap the menu icon to browse the site
              </p>
            </div>
          </main>

          {/* Slide-in menu */}
          <AnimatePresence>
            {open && (
              <motion.div
                key="overlay"
                initial="closed"
                animate="open"
                exit="closed"
                variants={{ open: {}, closed: {} }}
                className="absolute inset-0 z-30"
              >
                <motion.div
                  variants={backdropVariants}
                  transition={{ duration: reduce ? 0 : 0.22 }}
                  onClick={closeMenu}
                  aria-hidden="true"
                  className="absolute inset-0 z-0 cursor-pointer bg-slate-900/40 backdrop-blur-[2px] dark:bg-black/60"
                />

                <motion.div
                  key="drawer"
                  ref={drawerRef}
                  id={panelId}
                  role="dialog"
                  aria-modal="true"
                  aria-labelledby={titleId}
                  onKeyDown={onDrawerKeyDown}
                  variants={drawerVariants}
                  transition={
                    reduce
                      ? { duration: 0.15 }
                      : { type: "spring", stiffness: 360, damping: 36 }
                  }
                  className="absolute inset-y-0 left-0 z-10 flex w-[82%] max-w-[300px] flex-col border-r border-zinc-200 bg-white shadow-2xl shadow-black/25 dark:border-zinc-800 dark:bg-zinc-950"
                >
                  <div className="flex shrink-0 items-center justify-between border-b border-zinc-100 px-4 py-3.5 dark:border-zinc-800/80">
                    <div className="flex items-center gap-2.5">
                      {logoMark}
                      <span
                        id={titleId}
                        className="text-[15px] font-semibold tracking-tight text-zinc-900 dark:text-white"
                      >
                        Fathom &amp; Field
                      </span>
                    </div>
                    <button
                      type="button"
                      onClick={closeMenu}
                      aria-label="Close menu"
                      className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-white"
                    >
                      <Icon name="close" className="h-5 w-5" />
                    </button>
                  </div>

                  <nav
                    aria-label="Primary"
                    role="menu"
                    aria-orientation="vertical"
                    onKeyDown={onMenuKeyDown}
                    className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 py-3"
                  >
                    {NAV.map((node, i) => {
                      const isActive = activeKey === node.id;
                      return (
                        <div
                          key={node.id}
                          role="none"
                          className="sidemob-item"
                          style={{ animationDelay: `${i * 45}ms` }}
                        >
                          {isGroup(node) ? (
                            <>
                              <button
                                type="button"
                                role="menuitem"
                                data-menuitem="true"
                                aria-haspopup="true"
                                aria-expanded={expanded.includes(node.id)}
                                aria-controls={`${panelId}-${node.id}`}
                                tabIndex={isActive ? 0 : -1}
                                ref={(el) => {
                                  nodeRefs.current[node.id] = el;
                                }}
                                onClick={() => toggleGroup(node.id)}
                                onFocus={() => setActiveKey(node.id)}
                                className={cx(
                                  "group flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left text-[15px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
                                  isActive
                                    ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800/70 dark:text-white"
                                    : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-900",
                                )}
                              >
                                <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500 transition-colors group-hover:text-indigo-600 dark:bg-zinc-800 dark:text-zinc-400 dark:group-hover:text-indigo-400">
                                  <Icon name={node.icon} className="h-[18px] w-[18px]" />
                                </span>
                                <span className="flex-1 truncate">{node.label}</span>
                                <Icon
                                  name="chevron"
                                  className={cx(
                                    "h-4 w-4 text-zinc-400 transition-transform duration-300",
                                    expanded.includes(node.id) && "rotate-180",
                                  )}
                                />
                              </button>

                              {expanded.includes(node.id) && (
                                <ul
                                  id={`${panelId}-${node.id}`}
                                  role="menu"
                                  aria-label={`${node.label} pages`}
                                  className="mt-0.5 space-y-0.5 pl-[3.25rem] pr-1"
                                >
                                  {node.children.map((child) => {
                                    const childActive = activeKey === child.id;
                                    return (
                                      <li key={child.id} role="none">
                                        <a
                                          role="menuitem"
                                          data-menuitem="true"
                                          href={child.href}
                                          tabIndex={childActive ? 0 : -1}
                                          ref={(el) => {
                                            nodeRefs.current[child.id] = el;
                                          }}
                                          onFocus={() => setActiveKey(child.id)}
                                          onClick={closeMenu}
                                          className={cx(
                                            "block rounded-lg px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
                                            childActive
                                              ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800/60 dark:text-white"
                                              : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
                                          )}
                                        >
                                          {child.label}
                                        </a>
                                      </li>
                                    );
                                  })}
                                </ul>
                              )}
                            </>
                          ) : (
                            <a
                              role="menuitem"
                              data-menuitem="true"
                              href={node.href}
                              aria-current={node.id === "home" ? "page" : undefined}
                              tabIndex={isActive ? 0 : -1}
                              ref={(el) => {
                                nodeRefs.current[node.id] = el;
                              }}
                              onFocus={() => setActiveKey(node.id)}
                              onClick={closeMenu}
                              className={cx(
                                "group relative flex items-center gap-3 rounded-xl px-2.5 py-2 text-[15px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
                                node.id === "home"
                                  ? "text-indigo-700 dark:text-indigo-300"
                                  : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-900",
                                isActive &&
                                  node.id !== "home" &&
                                  "bg-zinc-100 dark:bg-zinc-800/70",
                                isActive &&
                                  node.id === "home" &&
                                  "bg-indigo-50 dark:bg-indigo-500/10",
                              )}
                            >
                              {node.id === "home" && (
                                <span
                                  aria-hidden="true"
                                  className="absolute inset-y-2 left-0 w-1 rounded-full bg-indigo-500"
                                />
                              )}
                              <span
                                className={cx(
                                  "flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors",
                                  node.id === "home"
                                    ? "bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
                                    : "bg-zinc-100 text-zinc-500 group-hover:text-indigo-600 dark:bg-zinc-800 dark:text-zinc-400 dark:group-hover:text-indigo-400",
                                )}
                              >
                                <Icon name={node.icon} className="h-[18px] w-[18px]" />
                              </span>
                              <span className="flex-1 truncate">{node.label}</span>
                              {node.badge && (
                                <span className="inline-flex items-center gap-1 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">
                                  <span className="sidemob-dot h-1.5 w-1.5 rounded-full bg-emerald-500" />
                                  {node.badge}
                                </span>
                              )}
                            </a>
                          )}
                        </div>
                      );
                    })}
                  </nav>

                  <div className="shrink-0 border-t border-zinc-100 px-4 py-4 dark:border-zinc-800/80">
                    <a
                      href="#contact"
                      onClick={closeMenu}
                      className="group relative flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition-transform hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
                    >
                      <span aria-hidden="true" className="sidemob-shine pointer-events-none absolute inset-0" />
                      <span className="relative">Start a project</span>
                      <Icon name="arrow" className="relative h-4 w-4" />
                    </a>
                    <dl className="mt-3 space-y-1 px-1 text-xs text-zinc-500 dark:text-zinc-400">
                      <div className="flex items-center justify-between">
                        <dt>Email</dt>
                        <dd className="font-medium text-zinc-700 dark:text-zinc-300">
                          studio@fathomfield.com
                        </dd>
                      </div>
                      <div className="flex items-center justify-between">
                        <dt>Studios</dt>
                        <dd className="font-medium text-zinc-700 dark:text-zinc-300">
                          Lisbon · New York
                        </dd>
                      </div>
                    </dl>
                  </div>
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>
        </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 →