Web InnoventixFreeCode

Profile Card Menu

Original · free

profile card dropdown with actions

byWeb InnoventixReact + Tailwind
menuprofilecardmenus
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-profile-card.json
menu-profile-card.tsx
"use client";

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

type IconProps = { className?: string };
type StatusId = "active" | "away" | "dnd";

type StatusOption = {
  id: StatusId;
  label: string;
  hint: string;
  dotClass: string;
};

type ActionItem = {
  id: string;
  label: string;
  description?: string;
  shortcut?: string;
  badge?: string;
  badgeClass?: string;
  icon: (p: IconProps) => ReactNode;
};

const USER = {
  name: "Jordan Avery",
  email: "jordan@northwind.studio",
  initials: "JA",
  role: "Product Designer",
  plan: "Pro",
  storageUsed: "6.2 GB",
  storageTotal: "10 GB",
  storagePct: 62,
};

const STATUS_OPTIONS: StatusOption[] = [
  { id: "active", label: "Active", hint: "Open to messages", dotClass: "bg-emerald-500" },
  { id: "away", label: "Away", hint: "Back in a little while", dotClass: "bg-amber-500" },
  { id: "dnd", label: "Do not disturb", hint: "Notifications muted", dotClass: "bg-rose-500" },
];

const STATUS_DOT: Record<StatusId, string> = {
  active: "bg-emerald-500",
  away: "bg-amber-500",
  dnd: "bg-rose-500",
};

function UserIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
      <circle cx="12" cy="7" r="4" />
    </svg>
  );
}

function SettingsIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1" />
    </svg>
  );
}

function CreditCardIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="2" y="5" width="20" height="14" rx="2.5" />
      <path d="M2 10h20M6 15h4" />
    </svg>
  );
}

function BellIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
      <path d="M13.7 21a2 2 0 0 1-3.4 0" />
    </svg>
  );
}

function CommandIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z" />
    </svg>
  );
}

function LifeBuoyIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="9" />
      <circle cx="12" cy="12" r="3.5" />
      <path d="M4.9 4.9 9.5 9.5M14.5 14.5l4.6 4.6M14.5 9.5l4.6-4.6M4.9 19.1 9.5 14.5" />
    </svg>
  );
}

function LogOutIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
      <path d="m16 17 5-5-5-5M21 12H9" />
    </svg>
  );
}

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

function CheckIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

const ACTIONS: ActionItem[] = [
  { id: "profile", label: "View profile", description: "Your public page", icon: UserIcon },
  { id: "settings", label: "Account settings", shortcut: "⌘,", icon: SettingsIcon },
  {
    id: "billing",
    label: "Billing & plans",
    badge: "Pro",
    badgeClass:
      "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
    icon: CreditCardIcon,
  },
  {
    id: "notifications",
    label: "Notifications",
    badge: "3",
    badgeClass: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    icon: BellIcon,
  },
  { id: "command", label: "Command menu", shortcut: "⌘K", icon: CommandIcon },
  { id: "help", label: "Help & support", icon: LifeBuoyIcon },
];

const STATUS_COUNT = STATUS_OPTIONS.length;
const ACTIONS_OFFSET = STATUS_COUNT;
const SIGN_OUT_INDEX = STATUS_COUNT + ACTIONS.length;
const FOCUSABLE_COUNT = SIGN_OUT_INDEX + 1;

function itemClass(active: boolean): string {
  return [
    "group/item flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors duration-150",
    "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
    active
      ? "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
      : "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800/70",
  ].join(" ");
}

export default function MenuProfileCard() {
  const reduce = useReducedMotion();
  const uid = useId();
  const triggerId = `${uid}-trigger`;
  const menuId = `${uid}-menu`;
  const statusLabelId = `${uid}-status-label`;

  const [isOpen, setIsOpen] = useState(false);
  const [status, setStatus] = useState<StatusId>("active");
  const [activeIndex, setActiveIndex] = useState(0);
  const [liveMessage, setLiveMessage] = useState("");

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const setItemRef = (i: number) => (el: HTMLButtonElement | null) => {
    itemRefs.current[i] = el;
  };

  const closeMenu = useCallback((returnFocus: boolean) => {
    setIsOpen(false);
    if (returnFocus) triggerRef.current?.focus();
  }, []);

  const openMenu = useCallback(() => {
    const idx = STATUS_OPTIONS.findIndex((s) => s.id === status);
    setActiveIndex(idx < 0 ? 0 : idx);
    setIsOpen(true);
  }, [status]);

  const toggle = () => {
    if (isOpen) closeMenu(false);
    else openMenu();
  };

  // Roving focus: move focus to the active item while the menu is open.
  useEffect(() => {
    if (!isOpen) return;
    itemRefs.current[activeIndex]?.focus();
  }, [isOpen, activeIndex]);

  // Close on outside pointer interaction.
  useEffect(() => {
    if (!isOpen) return;
    const onPointerDown = (e: PointerEvent) => {
      const target = e.target as Node;
      if (menuRef.current?.contains(target)) return;
      if (triggerRef.current?.contains(target)) return;
      setIsOpen(false);
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [isOpen]);

  const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((i) => (i + 1) % FOCUSABLE_COUNT);
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((i) => (i - 1 + FOCUSABLE_COUNT) % FOCUSABLE_COUNT);
        break;
      case "Home":
        e.preventDefault();
        setActiveIndex(0);
        break;
      case "End":
        e.preventDefault();
        setActiveIndex(FOCUSABLE_COUNT - 1);
        break;
      case "Escape":
        e.preventDefault();
        closeMenu(true);
        break;
      case "Tab":
        closeMenu(false);
        break;
      default:
        break;
    }
  };

  const selectStatus = (option: StatusOption, index: number) => {
    setStatus(option.id);
    setActiveIndex(index);
    setLiveMessage(`Status set to ${option.label}`);
    // Menu stays open so multiple adjustments feel natural.
  };

  const selectAction = (label: string) => {
    setLiveMessage(`${label} selected`);
    closeMenu(true);
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:py-24">
      <style>{`
        @keyframes mpc-pulse-ring {
          0%   { transform: scale(1);   opacity: 0.55; }
          70%  { transform: scale(2.4); opacity: 0; }
          100% { transform: scale(2.4); opacity: 0; }
        }
        @keyframes mpc-shimmer {
          0%   { transform: translateX(-140%); }
          100% { transform: translateX(260%); }
        }
        .mpc-pulse-ring { animation: mpc-pulse-ring 2.6s ease-out infinite; }
        .mpc-shimmer    { animation: mpc-shimmer 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .mpc-pulse-ring, .mpc-shimmer { animation: none !important; }
        }
      `}</style>

      {/* Ambient decoration */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute -right-16 top-1/3 h-72 w-72 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/10" />
      </div>

      <div className="relative mx-auto max-w-3xl">
        <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
          Account menu
        </p>
        <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
          Your workspace, one tap away
        </h2>
        <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
          Open the profile card to set your status and jump to any account action. Fully
          keyboard-driven &mdash; try{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            ↑ ↓
          </kbd>
          ,{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            Home
          </kbd>
          , and{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            Esc
          </kbd>
          .
        </p>

        {/* App-bar mock that anchors the menu */}
        <div className="mt-8 flex items-center justify-between rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70">
          <div className="flex items-center gap-2.5">
            <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-sm font-bold text-white shadow-sm">
              N
            </span>
            <span className="text-sm font-semibold tracking-tight text-slate-900 dark:text-white">
              Northwind
            </span>
          </div>

          {/* Trigger + dropdown wrapper */}
          <div className="relative">
            <button
              ref={triggerRef}
              type="button"
              id={triggerId}
              aria-haspopup="menu"
              aria-expanded={isOpen}
              aria-controls={isOpen ? menuId : undefined}
              aria-label={`Account menu for ${USER.name}`}
              onClick={toggle}
              className="flex items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1 pr-2.5 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700/70 dark:focus-visible:ring-offset-slate-900"
            >
              <span className="relative">
                <span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-xs font-bold text-white">
                  {USER.initials}
                </span>
                <span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center">
                  {status === "active" && !reduce && (
                    <span className="mpc-pulse-ring absolute h-2.5 w-2.5 rounded-full bg-emerald-500" />
                  )}
                  <span
                    className={`relative h-3 w-3 rounded-full ring-2 ring-white dark:ring-slate-800 ${STATUS_DOT[status]}`}
                  />
                </span>
              </span>
              <span className="hidden text-left sm:block">
                <span className="block text-xs font-semibold leading-tight text-slate-900 dark:text-white">
                  {USER.name}
                </span>
                <span className="block text-[11px] leading-tight text-slate-500 dark:text-slate-400">
                  {USER.plan} plan
                </span>
              </span>
              <ChevronIcon
                className={`h-4 w-4 text-slate-400 transition-transform duration-200 ${
                  isOpen ? "rotate-180" : ""
                }`}
              />
            </button>

            <AnimatePresence>
              {isOpen && (
                <motion.div
                  ref={menuRef}
                  id={menuId}
                  role="menu"
                  aria-labelledby={triggerId}
                  aria-orientation="vertical"
                  onKeyDown={onMenuKeyDown}
                  initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : -6 }}
                  animate={{ opacity: 1, scale: 1, y: 0 }}
                  exit={{ opacity: 0, scale: reduce ? 1 : 0.97, y: reduce ? 0 : -6 }}
                  transition={{ duration: reduce ? 0.12 : 0.18, ease: [0.16, 1, 0.3, 1] }}
                  className="absolute right-0 top-full z-50 mt-2 w-72 max-w-[calc(100vw-2rem)] origin-top-right overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 dark:border-slate-700/80 dark:bg-slate-900 dark:shadow-black/50 sm:w-80"
                >
                  {/* Profile header */}
                  <div className="flex items-center gap-3 px-4 pb-3 pt-4">
                    <span className="relative shrink-0">
                      <span className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-base font-bold text-white shadow-inner">
                        {USER.initials}
                      </span>
                      <span
                        className={`absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-white dark:ring-slate-900 ${STATUS_DOT[status]}`}
                      />
                    </span>
                    <div className="min-w-0 flex-1">
                      <div className="flex items-center gap-1.5">
                        <p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
                          {USER.name}
                        </p>
                        <span className="shrink-0 rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                          {USER.plan}
                        </span>
                      </div>
                      <p className="truncate text-xs text-slate-500 dark:text-slate-400">
                        {USER.email}
                      </p>
                    </div>
                  </div>

                  {/* Storage meter */}
                  <div className="px-4 pb-3">
                    <div className="mb-1.5 flex items-center justify-between text-[11px] font-medium">
                      <span className="text-slate-500 dark:text-slate-400">Storage</span>
                      <span className="tabular-nums text-slate-600 dark:text-slate-300">
                        {USER.storageUsed} <span className="text-slate-400">/ {USER.storageTotal}</span>
                      </span>
                    </div>
                    <div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                      <motion.div
                        className="relative h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
                        initial={{ width: reduce ? `${USER.storagePct}%` : 0 }}
                        animate={{ width: `${USER.storagePct}%` }}
                        transition={{ duration: reduce ? 0 : 0.6, ease: "easeOut", delay: reduce ? 0 : 0.08 }}
                      >
                        <span className="mpc-shimmer absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/50 to-transparent" />
                      </motion.div>
                    </div>
                  </div>

                  <div role="separator" className="mx-2 h-px bg-slate-200 dark:bg-slate-700/70" />

                  {/* Status radios */}
                  <div role="group" aria-labelledby={statusLabelId} className="p-1.5">
                    <p
                      id={statusLabelId}
                      className="px-2.5 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500"
                    >
                      Set your status
                    </p>
                    {STATUS_OPTIONS.map((option, i) => {
                      const checked = status === option.id;
                      return (
                        <button
                          key={option.id}
                          ref={setItemRef(i)}
                          type="button"
                          role="menuitemradio"
                          aria-checked={checked}
                          tabIndex={activeIndex === i ? 0 : -1}
                          onClick={() => selectStatus(option, i)}
                          onMouseEnter={() => setActiveIndex(i)}
                          className={itemClass(activeIndex === i)}
                        >
                          <span className="relative flex h-4 w-4 items-center justify-center">
                            <span className={`h-2.5 w-2.5 rounded-full ${option.dotClass}`} />
                          </span>
                          <span className="min-w-0 flex-1">
                            <span className="block truncate font-medium">{option.label}</span>
                            <span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
                              {option.hint}
                            </span>
                          </span>
                          {checked && (
                            <CheckIcon className="h-4 w-4 shrink-0 text-indigo-600 dark:text-indigo-400" />
                          )}
                        </button>
                      );
                    })}
                  </div>

                  <div role="separator" className="mx-2 h-px bg-slate-200 dark:bg-slate-700/70" />

                  {/* Actions */}
                  <div role="none" className="p-1.5">
                    {ACTIONS.map((action, idx) => {
                      const i = ACTIONS_OFFSET + idx;
                      const Icon = action.icon;
                      return (
                        <button
                          key={action.id}
                          ref={setItemRef(i)}
                          type="button"
                          role="menuitem"
                          tabIndex={activeIndex === i ? 0 : -1}
                          onClick={() => selectAction(action.label)}
                          onMouseEnter={() => setActiveIndex(i)}
                          className={itemClass(activeIndex === i)}
                        >
                          <Icon className="h-4 w-4 shrink-0 text-slate-400 transition-colors group-hover/item:text-slate-600 dark:text-slate-500 dark:group-hover/item:text-slate-300" />
                          <span className="min-w-0 flex-1">
                            <span className="block truncate font-medium">{action.label}</span>
                            {action.description && (
                              <span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
                                {action.description}
                              </span>
                            )}
                          </span>
                          {action.shortcut && (
                            <kbd className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-sans text-[10px] font-medium text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
                              {action.shortcut}
                            </kbd>
                          )}
                          {action.badge && (
                            <span
                              className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${action.badgeClass ?? ""}`}
                            >
                              {action.badge}
                            </span>
                          )}
                        </button>
                      );
                    })}
                  </div>

                  <div role="separator" className="mx-2 h-px bg-slate-200 dark:bg-slate-700/70" />

                  {/* Sign out */}
                  <div role="none" className="p-1.5">
                    <button
                      ref={setItemRef(SIGN_OUT_INDEX)}
                      type="button"
                      role="menuitem"
                      tabIndex={activeIndex === SIGN_OUT_INDEX ? 0 : -1}
                      onClick={() => selectAction("Sign out")}
                      onMouseEnter={() => setActiveIndex(SIGN_OUT_INDEX)}
                      className={[
                        "flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left text-sm font-medium outline-none transition-colors duration-150",
                        "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-500",
                        activeIndex === SIGN_OUT_INDEX
                          ? "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300"
                          : "text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-500/10",
                      ].join(" ")}
                    >
                      <LogOutIcon className="h-4 w-4 shrink-0" />
                      <span className="flex-1">Sign out</span>
                    </button>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        {/* Faux page content for context */}
        <div aria-hidden="true" className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
          {[0, 1, 2].map((n) => (
            <div
              key={n}
              className="rounded-2xl border border-slate-200 bg-white/60 p-4 dark:border-slate-800 dark:bg-slate-900/40"
            >
              <div className="h-2.5 w-16 rounded-full bg-slate-200 dark:bg-slate-800" />
              <div className="mt-3 h-6 w-24 rounded-md bg-slate-200/80 dark:bg-slate-800/80" />
              <div className="mt-4 h-2 w-full rounded-full bg-slate-100 dark:bg-slate-800/60" />
              <div className="mt-2 h-2 w-3/4 rounded-full bg-slate-100 dark:bg-slate-800/60" />
            </div>
          ))}
        </div>

        <p aria-live="polite" className="sr-only">
          {liveMessage}
        </p>
      </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 →