Web InnoventixFreeCode

Chat Group

Original · free

group chat with member names

byWeb InnoventixReact + Tailwind
chatgroup
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/chat-group.json
chat-group.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type MemberId = "priya" | "diego" | "amara" | "tomas" | "you";

type Member = {
  id: MemberId;
  name: string;
  handle: string;
  role: string;
  initials: string;
  ring: string;
  dot: string;
  tint: string;
  status: "online" | "away" | "offline";
};

type Message = {
  id: number;
  author: MemberId;
  body: string;
  at: string;
  reactions: Partial<Record<"fire" | "eyes" | "plus", MemberId[]>>;
  pending?: boolean;
};

const MEMBERS: Record<MemberId, Member> = {
  priya: {
    id: "priya",
    name: "Priya Raghavan",
    handle: "@priya",
    role: "Design lead",
    initials: "PR",
    ring: "ring-violet-400/60 dark:ring-violet-400/40",
    dot: "bg-violet-500",
    tint: "bg-violet-100 text-violet-900 dark:bg-violet-500/15 dark:text-violet-200",
    status: "online",
  },
  diego: {
    id: "diego",
    name: "Diego Ferreira",
    handle: "@diego",
    role: "Backend",
    initials: "DF",
    ring: "ring-emerald-400/60 dark:ring-emerald-400/40",
    dot: "bg-emerald-500",
    tint: "bg-emerald-100 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-200",
    status: "online",
  },
  amara: {
    id: "amara",
    name: "Amara Osei",
    handle: "@amara",
    role: "Product",
    initials: "AO",
    ring: "ring-amber-400/60 dark:ring-amber-400/40",
    dot: "bg-amber-500",
    tint: "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200",
    status: "away",
  },
  tomas: {
    id: "tomas",
    name: "Tomás Lindqvist",
    handle: "@tomas",
    role: "QA",
    initials: "TL",
    ring: "ring-sky-400/60 dark:ring-sky-400/40",
    dot: "bg-sky-500",
    tint: "bg-sky-100 text-sky-900 dark:bg-sky-500/15 dark:text-sky-200",
    status: "offline",
  },
  you: {
    id: "you",
    name: "You",
    handle: "@sal",
    role: "Engineering",
    initials: "SA",
    ring: "ring-indigo-400/60 dark:ring-indigo-400/40",
    dot: "bg-indigo-500",
    tint: "bg-indigo-100 text-indigo-900 dark:bg-indigo-500/15 dark:text-indigo-200",
    status: "online",
  },
};

const ORDER: MemberId[] = ["priya", "diego", "amara", "tomas", "you"];

const REACTIONS: { key: "fire" | "eyes" | "plus"; glyph: string; label: string }[] = [
  { key: "fire", glyph: "🔥", label: "Fire" },
  { key: "eyes", glyph: "👀", label: "Looking into it" },
  { key: "plus", glyph: "➕", label: "Plus one" },
];

const SEED: Message[] = [
  {
    id: 1,
    author: "amara",
    body: "Staging is on build 412. The checkout step still drops the coupon field when you go back a page.",
    at: "09:41",
    reactions: { eyes: ["tomas"] },
  },
  {
    id: 2,
    author: "diego",
    body: "That's on me — the session cache clears on the back nav. Patch is written, I just want Tomás to hammer it first.",
    at: "09:43",
    reactions: { plus: ["amara", "priya"] },
  },
  {
    id: 3,
    author: "tomas",
    body: "Give me the branch name and I'll run the regression suite against it this afternoon.",
    at: "09:44",
    reactions: {},
  },
  {
    id: 4,
    author: "priya",
    body: "While we're in there: the coupon input has a 34px tap target on mobile. Bumping it to 44 costs nothing and fixes half the complaints.",
    at: "09:47",
    reactions: { fire: ["amara"], plus: ["diego"] },
  },
];

const STATUS_LABEL: Record<Member["status"], string> = {
  online: "Online",
  away: "Away",
  offline: "Offline",
};

function StatusDot({ status }: { status: Member["status"] }) {
  const cls =
    status === "online"
      ? "bg-emerald-500"
      : status === "away"
        ? "bg-amber-500"
        : "bg-slate-400 dark:bg-slate-600";
  return (
    <span
      aria-hidden="true"
      className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-slate-950 ${cls}`}
    />
  );
}

function Avatar({ member, size = "md" }: { member: Member; size?: "sm" | "md" }) {
  const box = size === "sm" ? "h-7 w-7 text-[10px]" : "h-9 w-9 text-xs";
  return (
    <span className="relative inline-flex shrink-0">
      <span
        aria-hidden="true"
        className={`inline-flex items-center justify-center rounded-full font-semibold tracking-tight ring-2 ${box} ${member.ring} ${member.tint}`}
      >
        {member.initials}
      </span>
      <StatusDot status={member.status} />
    </span>
  );
}

function SendIcon() {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none">
      <path
        d="M3.4 9.6 16.2 3.8a.5.5 0 0 1 .67.67L11.06 17.3a.5.5 0 0 1-.93-.05l-1.6-5.1-5.1-1.6a.5.5 0 0 1-.03-.95Z"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5" fill="none">
      <path d="m5.5 5.5 9 9m0-9-9 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function PeopleIcon() {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none">
      <circle cx="7.5" cy="6.5" r="2.75" stroke="currentColor" strokeWidth="1.4" />
      <path d="M2.5 16c0-2.5 2.24-4.25 5-4.25S12.5 13.5 12.5 16" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      <path d="M13.75 4.2a2.75 2.75 0 0 1 0 5.1M14.5 11.9c2.03.42 3.5 1.94 3.5 4.1" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

export default function ChatGroup() {
  const reduced = useReducedMotion();
  const [messages, setMessages] = useState<Message[]>(SEED);
  const [draft, setDraft] = useState("");
  const [rosterOpen, setRosterOpen] = useState(false);
  const [typing, setTyping] = useState<MemberId | null>(null);
  const [live, setLive] = useState("");

  const nextId = useRef(SEED.length + 1);
  const logRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLTextAreaElement | null>(null);
  const rosterBtnRef = useRef<HTMLButtonElement | null>(null);
  const rosterRef = useRef<HTMLDivElement | null>(null);
  const timers = useRef<number[]>([]);
  const sendSeq = useRef(0);

  const rosterId = useId();
  const logId = useId();

  const online = useMemo(() => ORDER.filter((id) => MEMBERS[id].status === "online").length, []);

  useEffect(() => {
    const el = logRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
  }, [messages, typing, reduced]);

  useEffect(() => {
    const t = timers.current;
    return () => {
      t.forEach((n) => window.clearTimeout(n));
    };
  }, []);

  useEffect(() => {
    if (!rosterOpen) return;
    rosterRef.current?.focus();
  }, [rosterOpen]);

  useEffect(() => {
    if (!rosterOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setRosterOpen(false);
        rosterBtnRef.current?.focus();
        return;
      }
      if (e.key !== "Tab") return;
      const panel = rosterRef.current;
      if (!panel) return;
      const nodes = Array.from(panel.querySelectorAll<HTMLElement>("button:not([disabled])"));
      if (nodes.length === 0) {
        e.preventDefault();
        panel.focus();
        return;
      }
      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const active = document.activeElement;
      if (e.shiftKey) {
        if (active === first || active === panel) {
          e.preventDefault();
          last.focus();
        }
      } else if (active === last) {
        e.preventDefault();
        first.focus();
      }
    };
    const onClick = (e: MouseEvent) => {
      const t = e.target as Node;
      if (rosterRef.current?.contains(t) || rosterBtnRef.current?.contains(t)) return;
      setRosterOpen(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("mousedown", onClick);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("mousedown", onClick);
    };
  }, [rosterOpen]);

  const toggleReaction = useCallback((messageId: number, key: "fire" | "eyes" | "plus") => {
    setMessages((prev) =>
      prev.map((m) => {
        if (m.id !== messageId) return m;
        const current = m.reactions[key] ?? [];
        const has = current.includes("you");
        const next = has ? current.filter((x) => x !== "you") : [...current, "you"];
        const reactions = { ...m.reactions, [key]: next };
        if (next.length === 0) delete reactions[key];
        return { ...m, reactions };
      }),
    );
  }, []);

  const send = useCallback(() => {
    const body = draft.trim();
    if (!body) return;
    const seq = ++sendSeq.current;
    const id = nextId.current++;
    const at = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
    setMessages((prev) => [...prev, { id, author: "you", body, at, reactions: {}, pending: true }]);
    setDraft("");
    setLive("Message sent to Ship Room.");

    const t1 = window.setTimeout(() => {
      setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, pending: false } : m)));
    }, 620);

    const responder: MemberId = id % 2 === 0 ? "priya" : "diego";
    const t2 = window.setTimeout(() => setTyping(responder), 900);
    const t3 = window.setTimeout(() => {
      if (sendSeq.current === seq) setTyping(null);
      const replyId = nextId.current++;
      const reply =
        responder === "priya"
          ? "Noted — I'll drop the spacing spec in the thread so nobody has to guess the values."
          : "Works for me. I'll tag you on the PR once the tests go green.";
      setMessages((prev) => [
        ...prev,
        {
          id: replyId,
          author: responder,
          body: reply,
          at: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }),
          reactions: {},
        },
      ]);
      setLive(`${MEMBERS[responder].name} replied.`);
    }, 2400);

    timers.current.push(t1, t2, t3);
  }, [draft]);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes cgrp-typing-bob {
          0%, 60%, 100% { transform: translateY(0); opacity: .45; }
          30% { transform: translateY(-3px); opacity: 1; }
        }
        @keyframes cgrp-pulse-ring {
          0% { transform: scale(1); opacity: .55; }
          70% { transform: scale(2.2); opacity: 0; }
          100% { transform: scale(2.2); opacity: 0; }
        }
        .cgrp-dot { animation: cgrp-typing-bob 1.15s ease-in-out infinite; }
        .cgrp-dot:nth-child(2) { animation-delay: .15s; }
        .cgrp-dot:nth-child(3) { animation-delay: .3s; }
        .cgrp-ping { animation: cgrp-pulse-ring 2.4s cubic-bezier(0, 0, .2, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cgrp-dot, .cgrp-ping { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl">
        <header className="mb-6">
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Ship Room
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Five people, one release. Reactions, roster and replies all work — press{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
              Enter
            </kbd>{" "}
            to send.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Header bar */}
          <div className="relative flex items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
            <div aria-hidden="true" className="flex -space-x-2">
              {ORDER.slice(0, 4).map((id) => (
                <span
                  key={id}
                  title={MEMBERS[id].name}
                  className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-[10px] font-semibold ring-2 ring-white dark:ring-slate-900 ${MEMBERS[id].tint}`}
                >
                  {MEMBERS[id].initials}
                </span>
              ))}
            </div>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                #ship-room
              </p>
              <p className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
                <span className="relative inline-flex h-1.5 w-1.5">
                  <span className="cgrp-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                  <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
                </span>
                {online} of {ORDER.length} online
              </p>
            </div>
            <button
              ref={rosterBtnRef}
              type="button"
              onClick={() => setRosterOpen((v) => !v)}
              aria-expanded={rosterOpen}
              aria-controls={rosterId}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition 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:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
            >
              <PeopleIcon />
              Members
            </button>

            <AnimatePresence>
              {rosterOpen ? (
                <motion.div
                  ref={rosterRef}
                  id={rosterId}
                  role="dialog"
                  aria-modal="true"
                  aria-label="Members of #ship-room"
                  tabIndex={-1}
                  initial={reduced ? false : { opacity: 0, y: -6, scale: 0.98 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
                  transition={{ duration: 0.16, ease: "easeOut" }}
                  className="absolute right-3 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800"
                >
                  <div className="flex items-center justify-between gap-2 px-2 py-1.5">
                    <p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                      Members · {ORDER.length}
                    </p>
                    <button
                      type="button"
                      onClick={() => {
                        setRosterOpen(false);
                        rosterBtnRef.current?.focus();
                      }}
                      className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 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-700 dark:hover:text-slate-100"
                    >
                      <CloseIcon />
                      <span className="sr-only">Close members list</span>
                    </button>
                  </div>
                  <ul className="space-y-0.5">
                    {ORDER.map((id) => {
                      const m = MEMBERS[id];
                      return (
                        <li key={id} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5">
                          <Avatar member={m} size="sm" />
                          <span className="min-w-0 flex-1">
                            <span className="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">
                              {m.name}
                            </span>
                            <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                              {m.handle} · {m.role}
                            </span>
                          </span>
                          <span className="shrink-0 text-[11px] font-medium text-slate-500 dark:text-slate-400">
                            {STATUS_LABEL[m.status]}
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </motion.div>
              ) : null}
            </AnimatePresence>
          </div>

          {/* Message log */}
          <div
            ref={logRef}
            id={logId}
            role="log"
            aria-label="Ship Room messages"
            aria-live="polite"
            tabIndex={0}
            className="h-[26rem] space-y-4 overflow-y-auto px-4 py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
          >
            {messages.map((m, i) => {
              const member = MEMBERS[m.author];
              const mine = m.author === "you";
              const prev = messages[i - 1];
              const grouped = prev?.author === m.author;
              return (
                <motion.article
                  key={m.id}
                  initial={reduced ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.22, ease: "easeOut" }}
                  className={`flex gap-3 ${mine ? "flex-row-reverse" : ""}`}
                >
                  <span className={grouped ? "invisible" : ""}>
                    <Avatar member={member} />
                  </span>
                  <div className={`min-w-0 max-w-[82%] ${mine ? "items-end text-right" : ""} flex flex-col`}>
                    {!grouped ? (
                      <p className={`mb-1 flex items-baseline gap-2 text-xs ${mine ? "flex-row-reverse" : ""}`}>
                        <span className="font-semibold text-slate-900 dark:text-slate-100">{member.name}</span>
                        <span className="text-slate-400 dark:text-slate-500">{member.handle}</span>
                        <span className="text-slate-400 dark:text-slate-500">{m.at}</span>
                      </p>
                    ) : null}
                    <div
                      className={`inline-block rounded-2xl px-3.5 py-2.5 text-left text-sm leading-relaxed transition-opacity ${
                        mine
                          ? "bg-indigo-600 text-white dark:bg-indigo-500"
                          : "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200"
                      } ${m.pending ? "opacity-60" : "opacity-100"}`}
                    >
                      {m.body}
                    </div>

                    <div className={`mt-1.5 flex flex-wrap items-center gap-1 ${mine ? "justify-end" : ""}`}>
                      {REACTIONS.map((r) => {
                        const who = m.reactions[r.key] ?? [];
                        const active = who.includes("you");
                        const show = who.length > 0;
                        const names = who.map((id) => MEMBERS[id].name).join(", ");
                        return (
                          <button
                            key={r.key}
                            type="button"
                            onClick={() => toggleReaction(m.id, r.key)}
                            aria-pressed={active}
                            aria-label={
                              show
                                ? `${r.label}, ${who.length} ${who.length === 1 ? "person" : "people"}: ${names}`
                                : `React with ${r.label}`
                            }
                            title={show ? names : r.label}
                            className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs leading-5 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                              active
                                ? "border-indigo-400 bg-indigo-50 text-indigo-700 dark:border-indigo-400/50 dark:bg-indigo-500/15 dark:text-indigo-200"
                                : show
                                  ? "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600"
                                  : "border-transparent text-slate-400 opacity-50 hover:bg-slate-100 hover:opacity-100 focus-visible:opacity-100 dark:text-slate-500 dark:hover:bg-slate-800"
                            }`}
                          >
                            <span aria-hidden="true">{r.glyph}</span>
                            {show ? <span className="tabular-nums font-medium">{who.length}</span> : null}
                          </button>
                        );
                      })}
                    </div>
                  </div>
                </motion.article>
              );
            })}

            <AnimatePresence>
              {typing ? (
                <motion.div
                  key="typing"
                  initial={reduced ? false : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: 0.18 }}
                  className="flex items-center gap-3"
                >
                  <Avatar member={MEMBERS[typing]} />
                  <span className="inline-flex items-center gap-1 rounded-2xl bg-slate-100 px-3.5 py-3 dark:bg-slate-800">
                    <span className="sr-only">{MEMBERS[typing].name} is typing</span>
                    <span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
                    <span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
                    <span aria-hidden="true" className="cgrp-dot h-1.5 w-1.5 rounded-full bg-slate-500 dark:bg-slate-400" />
                  </span>
                </motion.div>
              ) : null}
            </AnimatePresence>
          </div>

          {/* Composer */}
          <form
            onSubmit={(e) => {
              e.preventDefault();
              send();
            }}
            className="border-t border-slate-200 p-3 dark:border-slate-800"
          >
            <div className="flex items-end gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2 transition focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/30 dark:border-slate-700 dark:bg-slate-950/40 dark:focus-within:border-indigo-400">
              <label htmlFor={`${logId}-composer`} className="sr-only">
                Message #ship-room
              </label>
              <textarea
                id={`${logId}-composer`}
                ref={inputRef}
                rows={1}
                value={draft}
                onChange={(e) => setDraft(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && !e.shiftKey) {
                    e.preventDefault();
                    send();
                  }
                }}
                placeholder="Message #ship-room…"
                className="max-h-28 min-h-[2.25rem] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
              />
              <button
                type="submit"
                disabled={draft.trim().length === 0}
                className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg bg-indigo-600 px-3 text-sm font-medium text-white transition 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-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-slate-900"
              >
                <SendIcon />
                Send
              </button>
            </div>
            <p className="mt-2 px-1 text-[11px] text-slate-400 dark:text-slate-500">
              Shift + Enter for a new line. Everyone in #ship-room sees this.
            </p>
          </form>
        </div>

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