Web InnoventixFreeCode

Command Menu

Original · free

command palette style menu with search

byWeb InnoventixReact + Tailwind
menucommandmenus
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/menu-command.json
menu-command.tsx
"use client";

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

type IconName =
  | "search"
  | "grid"
  | "folder"
  | "inbox"
  | "calendar"
  | "doc"
  | "user"
  | "upload"
  | "phone"
  | "moon"
  | "card"
  | "bell"
  | "book"
  | "life"
  | "keyboard";

type Command = {
  id: string;
  name: string;
  category: string;
  keywords: string[];
  icon: IconName;
  hint?: string[];
};

type Group = {
  category: string;
  items: Command[];
};

const CATEGORY_ORDER = ["Navigate", "Create", "Workspace", "Help"] as const;

const COMMANDS: Command[] = [
  {
    id: "nav-dashboard",
    name: "Go to Dashboard",
    category: "Navigate",
    keywords: ["home", "overview", "metrics", "start"],
    icon: "grid",
    hint: ["G", "D"],
  },
  {
    id: "nav-projects",
    name: "Open Projects",
    category: "Navigate",
    keywords: ["repos", "work", "boards", "tasks"],
    icon: "folder",
    hint: ["G", "P"],
  },
  {
    id: "nav-inbox",
    name: "Jump to Inbox",
    category: "Navigate",
    keywords: ["messages", "notifications", "unread", "mail"],
    icon: "inbox",
    hint: ["G", "I"],
  },
  {
    id: "nav-calendar",
    name: "View Calendar",
    category: "Navigate",
    keywords: ["schedule", "events", "meetings", "agenda"],
    icon: "calendar",
    hint: ["G", "C"],
  },
  {
    id: "create-doc",
    name: "New Document",
    category: "Create",
    keywords: ["file", "page", "write", "note", "add"],
    icon: "doc",
    hint: ["⌘", "N"],
  },
  {
    id: "create-invite",
    name: "Invite Teammate",
    category: "Create",
    keywords: ["member", "add people", "collaborator", "share access"],
    icon: "user",
  },
  {
    id: "create-upload",
    name: "Upload File",
    category: "Create",
    keywords: ["attach", "import", "media", "asset"],
    icon: "upload",
    hint: ["⌘", "U"],
  },
  {
    id: "create-call",
    name: "Start a Call",
    category: "Create",
    keywords: ["video", "meeting", "voice", "huddle"],
    icon: "phone",
  },
  {
    id: "ws-theme",
    name: "Toggle Dark Mode",
    category: "Workspace",
    keywords: ["theme", "appearance", "light", "night"],
    icon: "moon",
    hint: ["⌘", "D"],
  },
  {
    id: "ws-billing",
    name: "Manage Billing",
    category: "Workspace",
    keywords: ["plan", "invoice", "subscription", "payment"],
    icon: "card",
  },
  {
    id: "ws-notify",
    name: "Notification Preferences",
    category: "Workspace",
    keywords: ["alerts", "email", "push", "settings"],
    icon: "bell",
  },
  {
    id: "help-docs",
    name: "Read Documentation",
    category: "Help",
    keywords: ["guide", "manual", "learn", "reference"],
    icon: "book",
  },
  {
    id: "help-support",
    name: "Contact Support",
    category: "Help",
    keywords: ["help", "ticket", "chat", "assistance"],
    icon: "life",
  },
  {
    id: "help-shortcuts",
    name: "Keyboard Shortcuts",
    category: "Help",
    keywords: ["keys", "hotkeys", "bindings", "cheatsheet"],
    icon: "keyboard",
    hint: ["⌘", "/"],
  },
];

function Icon({ name }: { name: IconName }): JSX.Element {
  const common = {
    width: 18,
    height: 18,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.7,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  switch (name) {
    case "search":
      return (
        <svg {...common}>
          <circle cx="11" cy="11" r="7" />
          <path d="m20 20-3.6-3.6" />
        </svg>
      );
    case "grid":
      return (
        <svg {...common}>
          <rect x="3" y="3" width="7" height="7" rx="1.5" />
          <rect x="14" y="3" width="7" height="7" rx="1.5" />
          <rect x="14" y="14" width="7" height="7" rx="1.5" />
          <rect x="3" y="14" width="7" height="7" rx="1.5" />
        </svg>
      );
    case "folder":
      return (
        <svg {...common}>
          <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
        </svg>
      );
    case "inbox":
      return (
        <svg {...common}>
          <path d="M4 5h16l1 8v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3z" />
          <path d="M3 13h5l1.5 2.5h5L21 13" />
        </svg>
      );
    case "calendar":
      return (
        <svg {...common}>
          <rect x="3" y="4.5" width="18" height="16.5" rx="2" />
          <path d="M3 9.5h18M8 3v4M16 3v4" />
        </svg>
      );
    case "doc":
      return (
        <svg {...common}>
          <path d="M14 3v4a1 1 0 0 0 1 1h4" />
          <path d="M6 3h8l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
          <path d="M9 13h6M9 17h4" />
        </svg>
      );
    case "user":
      return (
        <svg {...common}>
          <circle cx="12" cy="8" r="4" />
          <path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
        </svg>
      );
    case "upload":
      return (
        <svg {...common}>
          <path d="M12 15V4M8 8l4-4 4 4" />
          <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
        </svg>
      );
    case "phone":
      return (
        <svg {...common}>
          <path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z" />
        </svg>
      );
    case "moon":
      return (
        <svg {...common}>
          <path d="M20 14.2A8 8 0 0 1 9.8 4 8 8 0 1 0 20 14.2z" />
        </svg>
      );
    case "card":
      return (
        <svg {...common}>
          <rect x="3" y="5" width="18" height="14" rx="2" />
          <path d="M3 10h18M7 15h4" />
        </svg>
      );
    case "bell":
      return (
        <svg {...common}>
          <path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6z" />
          <path d="M10 20a2 2 0 0 0 4 0" />
        </svg>
      );
    case "book":
      return (
        <svg {...common}>
          <path d="M4 5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2z" />
          <path d="M4 19a2 2 0 0 1 2-2h12" />
        </svg>
      );
    case "life":
      return (
        <svg {...common}>
          <circle cx="12" cy="12" r="9" />
          <circle cx="12" cy="12" r="3.4" />
          <path d="M5 5l4.6 4.6M14.4 14.4 19 19M19 5l-4.6 4.6M9.6 14.4 5 19" />
        </svg>
      );
    case "keyboard":
      return (
        <svg {...common}>
          <rect x="2" y="6" width="20" height="12" rx="2" />
          <path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M8 14h8" />
        </svg>
      );
  }
}

function Kbd({ children }: { children: string }): JSX.Element {
  return (
    <kbd className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-md border border-slate-300 bg-slate-100 px-1.5 font-sans text-[11px] font-semibold text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
      {children}
    </kbd>
  );
}

export default function MenuCommand(): JSX.Element {
  const reduce = useReducedMotion();
  const baseId = useId();
  const listboxId = `${baseId}-listbox`;

  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeId, setActiveId] = useState("");
  const [lastRun, setLastRun] = useState<{ name: string; at: number } | null>(
    null
  );

  const triggerRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const wasOpen = useRef(false);

  const optionDomId = useCallback(
    (id: string) => `${baseId}-opt-${id}`,
    [baseId]
  );

  const groups = useMemo<Group[]>(() => {
    const q = query.trim().toLowerCase();
    const tokens = q.length > 0 ? q.split(/\s+/) : [];
    const filtered =
      tokens.length === 0
        ? COMMANDS
        : COMMANDS.filter((c) => {
            const hay = `${c.name} ${c.category} ${c.keywords.join(" ")}`.toLowerCase();
            return tokens.every((t) => hay.includes(t));
          });
    return CATEGORY_ORDER.map((category) => ({
      category,
      items: filtered.filter((c) => c.category === category),
    })).filter((g) => g.items.length > 0);
  }, [query]);

  const flat = useMemo<Command[]>(
    () => groups.flatMap((g) => g.items),
    [groups]
  );

  const runCommand = useCallback((cmd: Command) => {
    setLastRun({ name: cmd.name, at: Date.now() });
    setOpen(false);
  }, []);

  const move = useCallback(
    (delta: number) => {
      if (flat.length === 0) return;
      const idx = flat.findIndex((c) => c.id === activeId);
      const from = idx === -1 ? 0 : idx;
      const next = (from + delta + flat.length) % flat.length;
      setActiveId(flat[next].id);
    },
    [flat, activeId]
  );

  // Keep the active option valid as results change.
  useEffect(() => {
    if (flat.length === 0) {
      setActiveId("");
      return;
    }
    if (!flat.some((c) => c.id === activeId)) {
      setActiveId(flat[0].id);
    }
  }, [flat, activeId]);

  // Scroll the active option into view.
  useEffect(() => {
    if (!activeId) return;
    itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
  }, [activeId]);

  // Reset query on open, focus input on open, restore trigger focus on close.
  useEffect(() => {
    if (open) {
      setQuery("");
      const t = window.setTimeout(() => inputRef.current?.focus(), 20);
      return () => window.clearTimeout(t);
    }
    if (wasOpen.current) {
      triggerRef.current?.focus();
    }
    wasOpen.current = open;
    return undefined;
  }, [open]);

  useEffect(() => {
    wasOpen.current = open;
  }, [open]);

  // Lock body scroll while open.
  useEffect(() => {
    if (!open) return undefined;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = prev;
    };
  }, [open]);

  // Global command-palette hotkey.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setOpen((o) => !o);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const onInputKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      move(1);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      move(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      if (flat.length > 0) setActiveId(flat[0].id);
    } else if (e.key === "End") {
      e.preventDefault();
      if (flat.length > 0) setActiveId(flat[flat.length - 1].id);
    } else if (e.key === "Enter") {
      e.preventDefault();
      const cmd = flat.find((c) => c.id === activeId);
      if (cmd) runCommand(cmd);
    }
  };

  const onDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      if (query.length > 0) {
        setQuery("");
      } else {
        setOpen(false);
      }
      return;
    }
    if (e.key !== "Tab") return;
    const root = panelRef.current;
    if (!root) return;
    const focusables = Array.from(
      root.querySelectorAll<HTMLElement>(
        'button, input, [href], [tabindex]:not([tabindex="-1"])'
      )
    ).filter((el) => !el.hasAttribute("disabled"));
    if (focusables.length === 0) return;
    const first = focusables[0];
    const last = focusables[focusables.length - 1];
    const current = document.activeElement;
    if (e.shiftKey && current === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && current === last) {
      e.preventDefault();
      first.focus();
    }
  };

  const onOverlayMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
    if (e.target === e.currentTarget) setOpen(false);
  };

  const panelInitial = reduce
    ? { opacity: 0 }
    : { opacity: 0, scale: 0.96, y: 10 };
  const panelAnimate = reduce
    ? { opacity: 1 }
    : { opacity: 1, scale: 1, y: 0 };
  const panelExit = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.985, y: 6 };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-slate-900 sm:py-32 dark:bg-slate-950 dark:text-slate-100">
      {/* Ambient background */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-70"
        style={{
          backgroundImage:
            "radial-gradient(60rem 40rem at 15% -10%, rgba(99,102,241,0.14), transparent 60%), radial-gradient(50rem 40rem at 110% 20%, rgba(139,92,246,0.12), transparent 55%)",
        }}
      />
      <div
        aria-hidden
        className="cmdk-anim pointer-events-none absolute -right-24 top-24 h-72 w-72 rounded-full bg-violet-400/10 blur-3xl dark:bg-violet-500/10"
        style={{ animation: "cmdkfloat 12s ease-in-out infinite" }}
      />

      <div className="relative mx-auto flex max-w-2xl flex-col items-start gap-8">
        <div className="flex flex-col gap-3">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-indigo-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-indigo-300">
            <span
              className="cmdk-anim inline-block h-1.5 w-1.5 rounded-full bg-indigo-500"
              style={{ animation: "cmdkpulse 2.4s ease-in-out infinite" }}
            />
            Command Palette
          </span>
          <h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            Do anything, without leaving the keyboard.
          </h2>
          <p className="max-w-lg text-pretty text-sm leading-relaxed text-slate-500 dark:text-slate-400">
            Search across navigation, creation, and workspace actions. Open it
            anywhere with a shortcut, then drive it entirely with arrow keys and
            Enter.
          </p>
        </div>

        <button
          ref={triggerRef}
          type="button"
          onClick={() => setOpen(true)}
          aria-haspopup="dialog"
          aria-expanded={open}
          className="group flex w-full max-w-md items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3.5 text-left shadow-sm transition-colors hover:border-indigo-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-600 dark:focus-visible:ring-offset-slate-950"
        >
          <span className="text-slate-400 transition-colors group-hover:text-indigo-500 dark:text-slate-500">
            <Icon name="search" />
          </span>
          <span className="flex-1 text-sm text-slate-400 dark:text-slate-500">
            Search commands, files, and actions…
          </span>
          <span className="flex items-center gap-1">
            <Kbd>⌘</Kbd>
            <Kbd>K</Kbd>
          </span>
        </button>

        <div
          role="status"
          aria-live="polite"
          className="min-h-[1.5rem] text-sm text-slate-500 dark:text-slate-400"
        >
          {lastRun ? (
            <span>
              Last run:{" "}
              <span className="font-medium text-indigo-600 dark:text-indigo-300">
                {lastRun.name}
              </span>
            </span>
          ) : (
            <span className="text-slate-400 dark:text-slate-500">
              Tip: press{" "}
              <span className="font-medium text-slate-500 dark:text-slate-400">
                ⌘K
              </span>{" "}
              to open the palette.
            </span>
          )}
        </div>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="cmdk-overlay"
            className="fixed inset-0 z-50 flex items-start justify-center bg-slate-950/50 px-4 pt-[12vh] backdrop-blur-sm"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.16, ease: "easeOut" }}
            onMouseDown={onOverlayMouseDown}
          >
            <motion.div
              ref={panelRef}
              role="dialog"
              aria-modal="true"
              aria-label="Command palette"
              className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-slate-900/20 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/50"
              initial={panelInitial}
              animate={panelAnimate}
              exit={panelExit}
              transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
              onKeyDown={onDialogKeyDown}
              onMouseDown={(e) => e.stopPropagation()}
            >
              {/* Accent shimmer line */}
              <span
                aria-hidden
                className="cmdk-anim absolute inset-x-0 top-0 h-px"
                style={{
                  backgroundImage:
                    "linear-gradient(90deg, transparent, rgba(99,102,241,0.85), rgba(139,92,246,0.85), transparent)",
                  backgroundSize: "200% 100%",
                  animation: "cmdkshimmer 3.5s linear infinite",
                }}
              />

              {/* Search header */}
              <div className="flex items-center gap-3 border-b border-slate-200 px-4 py-3.5 dark:border-slate-800">
                <span className="text-indigo-500 dark:text-indigo-400">
                  <Icon name="search" />
                </span>
                <input
                  ref={inputRef}
                  type="text"
                  role="combobox"
                  aria-expanded
                  aria-controls={listboxId}
                  aria-activedescendant={
                    activeId ? optionDomId(activeId) : undefined
                  }
                  aria-autocomplete="list"
                  aria-label="Search commands"
                  autoComplete="off"
                  spellCheck={false}
                  value={query}
                  onChange={(e: ChangeEvent<HTMLInputElement>) =>
                    setQuery(e.target.value)
                  }
                  onKeyDown={onInputKeyDown}
                  placeholder="Search commands, files, and actions…"
                  className="w-full bg-transparent text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
                />
                <span className="hidden shrink-0 items-center gap-1 text-[11px] text-slate-400 sm:flex dark:text-slate-500">
                  <Kbd>Esc</Kbd>
                </span>
              </div>

              {/* Results */}
              <div
                id={listboxId}
                role="listbox"
                aria-label="Commands"
                className="max-h-[min(60vh,380px)] overflow-y-auto overscroll-contain p-2"
              >
                {groups.length === 0 ? (
                  <div className="px-3 py-10 text-center">
                    <p className="text-sm font-medium text-slate-600 dark:text-slate-300">
                      No results for &ldquo;{query.trim()}&rdquo;
                    </p>
                    <p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
                      Try a broader term like &ldquo;new&rdquo; or
                      &ldquo;settings&rdquo;.
                    </p>
                  </div>
                ) : (
                  groups.map((group) => {
                    const headingId = `${baseId}-grp-${group.category}`;
                    return (
                      <div
                        key={group.category}
                        role="group"
                        aria-labelledby={headingId}
                        className="mb-1 last:mb-0"
                      >
                        <div
                          id={headingId}
                          className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500"
                        >
                          {group.category}
                        </div>
                        {group.items.map((cmd) => {
                          const active = cmd.id === activeId;
                          return (
                            <div
                              key={cmd.id}
                              id={optionDomId(cmd.id)}
                              role="option"
                              aria-selected={active}
                              ref={(el) => {
                                if (el) itemRefs.current.set(cmd.id, el);
                                else itemRefs.current.delete(cmd.id);
                              }}
                              onMouseMove={() => setActiveId(cmd.id)}
                              onClick={() => runCommand(cmd)}
                              className={[
                                "flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
                                active
                                  ? "bg-indigo-50 text-slate-900 dark:bg-indigo-500/15 dark:text-white"
                                  : "text-slate-600 dark:text-slate-300",
                              ].join(" ")}
                            >
                              <span
                                className={[
                                  "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
                                  active
                                    ? "bg-indigo-500 text-white"
                                    : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                                ].join(" ")}
                              >
                                <Icon name={cmd.icon} />
                              </span>
                              <span className="flex-1 truncate font-medium">
                                {cmd.name}
                              </span>
                              {active && (
                                <span
                                  aria-hidden
                                  className="hidden items-center gap-1 text-indigo-500 sm:flex dark:text-indigo-300"
                                >
                                  <Icon name="search" />
                                </span>
                              )}
                              {cmd.hint && cmd.hint.length > 0 && (
                                <span className="flex shrink-0 items-center gap-1">
                                  {cmd.hint.map((k, i) => (
                                    <Kbd key={`${cmd.id}-${i}`}>{k}</Kbd>
                                  ))}
                                </span>
                              )}
                            </div>
                          );
                        })}
                      </div>
                    );
                  })
                )}
              </div>

              {/* Footer */}
              <div className="flex items-center justify-between gap-4 border-t border-slate-200 px-4 py-2.5 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
                <div className="flex items-center gap-3">
                  <span className="flex items-center gap-1.5">
                    <Kbd>↑</Kbd>
                    <Kbd>↓</Kbd>
                    <span className="hidden sm:inline">navigate</span>
                  </span>
                  <span className="flex items-center gap-1.5">
                    <Kbd>↵</Kbd>
                    <span className="hidden sm:inline">select</span>
                  </span>
                  <span className="flex items-center gap-1.5">
                    <Kbd>Esc</Kbd>
                    <span className="hidden sm:inline">close</span>
                  </span>
                </div>
                <span className="tabular-nums">
                  {flat.length} {flat.length === 1 ? "result" : "results"}
                </span>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>

      <style>{`
        @keyframes cmdkshimmer {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        @keyframes cmdkpulse {
          0%, 100% { opacity: 0.35; transform: scale(0.9); }
          50% { opacity: 1; transform: scale(1.15); }
        }
        @keyframes cmdkfloat {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-16px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .cmdk-anim { animation: none !important; }
        }
      `}</style>
    </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 →