Web InnoventixFreeCode

Expandable Table

Original · free

table with expandable rows

byWeb InnoventixReact + Tailwind
tableexpandabletables
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/table-expandable.json
table-expandable.tsx
"use client";

import { Fragment, useCallback, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type DeployStatus = "succeeded" | "failed" | "building" | "queued";
type Environment = "Production" | "Staging" | "Preview";
type CheckState = "pass" | "fail" | "running" | "pending";
type FileKind = "added" | "modified" | "removed";

interface DeployCheck {
  label: string;
  state: CheckState;
  detail: string;
}

interface ChangedFile {
  path: string;
  kind: FileKind;
}

interface Deployment {
  id: string;
  service: string;
  status: DeployStatus;
  environment: Environment;
  duration: string;
  deployedAt: string;
  commit: string;
  sha: string;
  branch: string;
  author: string;
  checks: DeployCheck[];
  files: ChangedFile[];
}

const deployments: Deployment[] = [
  {
    id: "checkout-service",
    service: "checkout-service",
    status: "succeeded",
    environment: "Production",
    duration: "3m 42s",
    deployedAt: "2h ago",
    commit: "Fix idempotency key collision on retried charges",
    sha: "a1f9c2e",
    branch: "main",
    author: "Priya Nair",
    checks: [
      { label: "Unit tests", state: "pass", detail: "428 passed" },
      { label: "Integration", state: "pass", detail: "61 passed" },
      { label: "Lint & types", state: "pass", detail: "no issues" },
      { label: "Security scan", state: "pass", detail: "0 vulnerabilities" },
    ],
    files: [
      { path: "services/checkout/idempotency.ts", kind: "added" },
      { path: "services/checkout/charge.ts", kind: "modified" },
      { path: "tests/charge.spec.ts", kind: "modified" },
    ],
  },
  {
    id: "web-storefront",
    service: "web-storefront",
    status: "building",
    environment: "Preview",
    duration: "1m 08s",
    deployedAt: "just now",
    commit: "Migrate hero carousel to server components",
    sha: "7d3b410",
    branch: "feat/rsc-hero",
    author: "Devon Alvarez",
    checks: [
      { label: "Unit tests", state: "running", detail: "312 / 540" },
      { label: "Bundle budget", state: "running", detail: "measuring" },
      { label: "Lint & types", state: "pass", detail: "no issues" },
      { label: "Visual diff", state: "pending", detail: "queued" },
    ],
    files: [
      { path: "app/(shop)/hero-carousel.tsx", kind: "modified" },
      { path: "app/(shop)/hero.client.tsx", kind: "removed" },
      { path: "lib/media/loader.ts", kind: "added" },
    ],
  },
  {
    id: "auth-gateway",
    service: "auth-gateway",
    status: "failed",
    environment: "Production",
    duration: "0m 54s",
    deployedAt: "18m ago",
    commit: "Rotate JWT signing keys to AWS KMS",
    sha: "c05e8ab",
    branch: "main",
    author: "Marcus Bell",
    checks: [
      { label: "Unit tests", state: "pass", detail: "203 passed" },
      { label: "Integration", state: "fail", detail: "KMS: permission denied" },
      { label: "Lint & types", state: "pass", detail: "no issues" },
      { label: "Security scan", state: "pass", detail: "0 vulnerabilities" },
    ],
    files: [
      { path: "services/auth/jwt.ts", kind: "modified" },
      { path: "infra/kms-policy.json", kind: "added" },
    ],
  },
  {
    id: "search-indexer",
    service: "search-indexer",
    status: "succeeded",
    environment: "Staging",
    duration: "6m 20s",
    deployedAt: "1h ago",
    commit: "Add typo-tolerant fuzzy matching to product search",
    sha: "4be1d77",
    branch: "feat/fuzzy",
    author: "Hana Okafor",
    checks: [
      { label: "Unit tests", state: "pass", detail: "894 passed" },
      { label: "Integration", state: "pass", detail: "reindex verified" },
      { label: "Lint & types", state: "pass", detail: "no issues" },
    ],
    files: [
      { path: "indexer/tokenizer.ts", kind: "modified" },
      { path: "indexer/fuzzy.ts", kind: "added" },
      { path: "config/ranking.yaml", kind: "modified" },
    ],
  },
  {
    id: "billing-worker",
    service: "billing-worker",
    status: "queued",
    environment: "Staging",
    duration: "—",
    deployedAt: "queued",
    commit: "Batch dunning emails by billing cycle",
    sha: "9ff02c1",
    branch: "fix/dunning",
    author: "Priya Nair",
    checks: [
      { label: "Unit tests", state: "pending", detail: "waiting for runner" },
      { label: "Integration", state: "pending", detail: "waiting for runner" },
    ],
    files: [],
  },
  {
    id: "notifications-api",
    service: "notifications-api",
    status: "succeeded",
    environment: "Production",
    duration: "2m 11s",
    deployedAt: "4h ago",
    commit: "Debounce duplicate push notifications within a 30s window",
    sha: "e21770d",
    branch: "main",
    author: "Sam Whitfield",
    checks: [
      { label: "Unit tests", state: "pass", detail: "176 passed" },
      { label: "Integration", state: "pass", detail: "34 passed" },
      { label: "Lint & types", state: "pass", detail: "no issues" },
    ],
    files: [
      { path: "services/push/debounce.ts", kind: "added" },
      { path: "services/push/dispatch.ts", kind: "modified" },
    ],
  },
];

const statusMeta: Record<
  DeployStatus,
  { label: string; badge: string; dot: string; pulse: boolean }
> = {
  succeeded: {
    label: "Succeeded",
    badge:
      "border-emerald-300/70 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300",
    dot: "bg-emerald-500",
    pulse: false,
  },
  failed: {
    label: "Failed",
    badge:
      "border-rose-300/70 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300",
    dot: "bg-rose-500",
    pulse: false,
  },
  building: {
    label: "Building",
    badge:
      "border-amber-300/70 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300",
    dot: "bg-amber-500",
    pulse: true,
  },
  queued: {
    label: "Queued",
    badge:
      "border-sky-300/70 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300",
    dot: "bg-sky-500",
    pulse: true,
  },
};

const fileKindMeta: Record<FileKind, { sign: string; className: string; label: string }> = {
  added: {
    sign: "+",
    label: "added",
    className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  },
  modified: {
    sign: "~",
    label: "modified",
    className: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  },
  removed: {
    sign: "−",
    label: "removed",
    className: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  },
};

const envFilters: { value: "all" | Environment; label: string }[] = [
  { value: "all", label: "All" },
  { value: "Production", label: "Production" },
  { value: "Staging", label: "Staging" },
  { value: "Preview", label: "Preview" },
];

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M7.5 5l5 5-5 5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CheckStateIcon({ state }: { state: CheckState }) {
  if (state === "pass") {
    return (
      <svg viewBox="0 0 20 20" className="h-4 w-4 text-emerald-500" aria-hidden="true">
        <circle cx="10" cy="10" r="9" fill="currentColor" opacity="0.15" />
        <path
          d="M6 10.4l2.6 2.6L14.2 7"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.7"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    );
  }
  if (state === "fail") {
    return (
      <svg viewBox="0 0 20 20" className="h-4 w-4 text-rose-500" aria-hidden="true">
        <circle cx="10" cy="10" r="9" fill="currentColor" opacity="0.15" />
        <path
          d="M7 7l6 6M13 7l-6 6"
          stroke="currentColor"
          strokeWidth="1.7"
          strokeLinecap="round"
        />
      </svg>
    );
  }
  if (state === "running") {
    return (
      <span
        className="texp-dot-pulse inline-flex h-4 w-4 items-center justify-center"
        aria-hidden="true"
      >
        <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
      </span>
    );
  }
  return (
    <span className="inline-flex h-4 w-4 items-center justify-center" aria-hidden="true">
      <span className="h-2.5 w-2.5 rounded-full border-2 border-slate-300 dark:border-slate-600" />
    </span>
  );
}

const checkStateLabel: Record<CheckState, string> = {
  pass: "passed",
  fail: "failed",
  running: "running",
  pending: "pending",
};

export default function TableExpandable() {
  const prefersReduced = useReducedMotion();
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["checkout-service"]));
  const [env, setEnv] = useState<"all" | Environment>("all");
  const [copiedSha, setCopiedSha] = useState<string | null>(null);
  const copyTimer = useRef<number | null>(null);

  const filtered = env === "all" ? deployments : deployments.filter((d) => d.environment === env);
  const allExpanded = filtered.length > 0 && filtered.every((d) => expanded.has(d.id));

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

  const toggleAll = useCallback(() => {
    setExpanded((prev) => {
      const everyOpen = filtered.length > 0 && filtered.every((d) => prev.has(d.id));
      if (everyOpen) {
        const next = new Set(prev);
        filtered.forEach((d) => next.delete(d.id));
        return next;
      }
      const next = new Set(prev);
      filtered.forEach((d) => next.add(d.id));
      return next;
    });
  }, [filtered]);

  const copySha = useCallback((sha: string) => {
    if (typeof navigator !== "undefined" && navigator.clipboard) {
      navigator.clipboard.writeText(sha).catch(() => undefined);
    }
    setCopiedSha(sha);
    if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopiedSha(null), 1600);
  }, []);

  const panelTransition = prefersReduced
    ? { duration: 0 }
    : { duration: 0.28, ease: "easeOut" as const };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-24">
      <style>{`
        @keyframes texp-dot-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.6); }
        }
        .texp-dot-pulse { animation: texp-dot-pulse 1.1s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .texp-dot-pulse { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-6 flex flex-col gap-1">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Delivery
          </p>
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            Deployment activity
          </h2>
          <p className="max-w-2xl text-sm text-slate-600 dark:text-slate-400">
            Every push to your services, with build checks and changed files one expand away.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Toolbar */}
          <div className="flex flex-col gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between sm:px-6">
            <div
              className="flex flex-wrap items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800/70"
              role="group"
              aria-label="Filter deployments by environment"
            >
              {envFilters.map((f) => {
                const active = env === f.value;
                return (
                  <button
                    key={f.value}
                    type="button"
                    aria-pressed={active}
                    onClick={() => setEnv(f.value)}
                    className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
                      active
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                        : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {f.label}
                  </button>
                );
              })}
            </div>

            <div className="flex items-center gap-3">
              <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                {filtered.length} {filtered.length === 1 ? "deploy" : "deploys"}
              </span>
              <button
                type="button"
                onClick={toggleAll}
                disabled={filtered.length === 0}
                aria-pressed={allExpanded}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 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 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                <ChevronIcon
                  className={`h-3.5 w-3.5 transition-transform duration-200 motion-reduce:transition-none ${
                    allExpanded ? "rotate-90" : ""
                  }`}
                />
                {allExpanded ? "Collapse all" : "Expand all"}
              </button>
            </div>
          </div>

          {/* Table */}
          <div className="overflow-x-auto">
            <table className="w-full min-w-[640px] border-collapse text-left">
              <caption className="sr-only">
                Recent deployments. Activate a row to reveal commit, checks, and changed files.
              </caption>
              <thead>
                <tr className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
                  <th scope="col" className="px-4 py-3 font-medium sm:px-6">
                    Deployment
                  </th>
                  <th scope="col" className="px-4 py-3 font-medium">
                    Status
                  </th>
                  <th scope="col" className="hidden px-4 py-3 font-medium md:table-cell">
                    Environment
                  </th>
                  <th scope="col" className="hidden px-4 py-3 font-medium lg:table-cell">
                    Duration
                  </th>
                  <th scope="col" className="hidden px-4 py-3 font-medium sm:table-cell">
                    Deployed
                  </th>
                </tr>
              </thead>
              <tbody className="divide-y divide-slate-200 dark:divide-slate-800">
                {filtered.map((d) => {
                  const open = expanded.has(d.id);
                  const meta = statusMeta[d.status];
                  const detailId = `texp-detail-${d.id}`;
                  return (
                    <Fragment key={d.id}>
                      <tr
                        className={`transition-colors ${
                          open
                            ? "bg-slate-50/80 dark:bg-slate-800/40"
                            : "hover:bg-slate-50 dark:hover:bg-slate-800/30"
                        }`}
                      >
                        <td className="px-2 py-1 sm:px-4">
                          <button
                            type="button"
                            onClick={() => toggle(d.id)}
                            aria-expanded={open}
                            aria-controls={detailId}
                            className="flex w-full items-center gap-3 rounded-lg px-2 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                          >
                            <span className="flex h-6 w-6 flex-none items-center justify-center rounded-md border border-slate-200 text-slate-500 dark:border-slate-700 dark:text-slate-400">
                              <ChevronIcon
                                className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${
                                  open ? "rotate-90" : ""
                                }`}
                              />
                            </span>
                            <span className="min-w-0">
                              <span className="block truncate font-mono text-sm font-medium text-slate-900 dark:text-white">
                                {d.service}
                              </span>
                              <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                                {d.commit}
                              </span>
                            </span>
                          </button>
                        </td>

                        <td className="px-4 py-3 align-middle">
                          <span
                            className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${meta.badge}`}
                          >
                            <span
                              className={`h-1.5 w-1.5 rounded-full ${meta.dot} ${
                                meta.pulse ? "texp-dot-pulse" : ""
                              }`}
                            />
                            {meta.label}
                          </span>
                        </td>

                        <td className="hidden px-4 py-3 align-middle text-sm text-slate-600 dark:text-slate-300 md:table-cell">
                          {d.environment}
                        </td>
                        <td className="hidden px-4 py-3 align-middle text-sm tabular-nums text-slate-600 dark:text-slate-300 lg:table-cell">
                          {d.duration}
                        </td>
                        <td className="hidden px-4 py-3 align-middle text-sm text-slate-500 dark:text-slate-400 sm:table-cell">
                          {d.deployedAt}
                        </td>
                      </tr>

                      <tr>
                        <td colSpan={5} className="p-0">
                          <div id={detailId}>
                            <AnimatePresence initial={false}>
                              {open && (
                                <motion.div
                                  key="panel"
                                  role="region"
                                  aria-label={`Details for ${d.service} deployment ${d.sha}`}
                                  initial={{ height: 0, opacity: 0 }}
                                  animate={{ height: "auto", opacity: 1 }}
                                  exit={{ height: 0, opacity: 0 }}
                                  transition={panelTransition}
                                  className="overflow-hidden bg-slate-50/60 dark:bg-slate-800/25"
                                >
                                  <div className="grid gap-x-8 gap-y-6 border-t border-slate-200 px-4 py-5 dark:border-slate-800 sm:px-6 lg:grid-cols-12">
                                    {/* Commit */}
                                    <div className="lg:col-span-5">
                                      <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                                        Commit
                                      </h3>
                                      <p className="mt-2 text-sm text-slate-800 dark:text-slate-200">
                                        {d.commit}
                                      </p>
                                      <dl className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
                                        <div className="flex items-center gap-1">
                                          <dt className="sr-only">Author</dt>
                                          <dd>{d.author}</dd>
                                        </div>
                                        <div className="flex items-center gap-1">
                                          <dt className="sr-only">Branch</dt>
                                          <dd className="font-mono text-slate-600 dark:text-slate-300">
                                            {d.branch}
                                          </dd>
                                        </div>
                                      </dl>
                                      <div className="mt-3 flex items-center gap-2">
                                        <code className="rounded-md bg-slate-200/70 px-2 py-1 font-mono text-xs text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
                                          {d.sha}
                                        </code>
                                        <button
                                          type="button"
                                          onClick={() => copySha(d.sha)}
                                          className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 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-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-800"
                                        >
                                          {copiedSha === d.sha ? (
                                            <>
                                              <svg
                                                viewBox="0 0 20 20"
                                                className="h-3.5 w-3.5 text-emerald-500"
                                                aria-hidden="true"
                                              >
                                                <path
                                                  d="M5 10.5l3.2 3.2L15 7"
                                                  fill="none"
                                                  stroke="currentColor"
                                                  strokeWidth="1.8"
                                                  strokeLinecap="round"
                                                  strokeLinejoin="round"
                                                />
                                              </svg>
                                              Copied
                                            </>
                                          ) : (
                                            <>
                                              <svg
                                                viewBox="0 0 20 20"
                                                className="h-3.5 w-3.5"
                                                fill="none"
                                                aria-hidden="true"
                                              >
                                                <rect
                                                  x="7"
                                                  y="7"
                                                  width="9"
                                                  height="9"
                                                  rx="2"
                                                  stroke="currentColor"
                                                  strokeWidth="1.5"
                                                />
                                                <path
                                                  d="M13 4H6a2 2 0 00-2 2v7"
                                                  stroke="currentColor"
                                                  strokeWidth="1.5"
                                                  strokeLinecap="round"
                                                />
                                              </svg>
                                              Copy SHA
                                            </>
                                          )}
                                        </button>
                                        <span aria-live="polite" className="sr-only">
                                          {copiedSha === d.sha ? `Copied ${d.sha}` : ""}
                                        </span>
                                      </div>
                                    </div>

                                    {/* Checks */}
                                    <div className="lg:col-span-4">
                                      <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                                        Checks
                                      </h3>
                                      <ul className="mt-2 space-y-2">
                                        {d.checks.map((c) => (
                                          <li key={c.label} className="flex items-center gap-2.5">
                                            <CheckStateIcon state={c.state} />
                                            <span className="text-sm text-slate-700 dark:text-slate-200">
                                              {c.label}
                                            </span>
                                            <span className="ml-auto text-xs tabular-nums text-slate-500 dark:text-slate-400">
                                              {c.detail}
                                            </span>
                                            <span className="sr-only">
                                              {checkStateLabel[c.state]}
                                            </span>
                                          </li>
                                        ))}
                                      </ul>
                                    </div>

                                    {/* Files */}
                                    <div className="lg:col-span-3">
                                      <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                                        Changed files
                                      </h3>
                                      {d.files.length === 0 ? (
                                        <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
                                          No files yet — deploy has not started.
                                        </p>
                                      ) : (
                                        <ul className="mt-2 space-y-1.5">
                                          {d.files.map((f) => {
                                            const fk = fileKindMeta[f.kind];
                                            return (
                                              <li key={f.path} className="flex items-center gap-2">
                                                <span
                                                  className={`flex h-5 w-5 flex-none items-center justify-center rounded font-mono text-xs font-bold ${fk.className}`}
                                                  aria-hidden="true"
                                                >
                                                  {fk.sign}
                                                </span>
                                                <span className="truncate font-mono text-xs text-slate-600 dark:text-slate-300">
                                                  {f.path}
                                                </span>
                                                <span className="sr-only">{fk.label}</span>
                                              </li>
                                            );
                                          })}
                                        </ul>
                                      )}
                                    </div>
                                  </div>
                                </motion.div>
                              )}
                            </AnimatePresence>
                          </div>
                        </td>
                      </tr>
                    </Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>

          {filtered.length === 0 && (
            <p className="px-6 py-10 text-center text-sm text-slate-500 dark:text-slate-400">
              No deployments in this environment yet.
            </p>
          )}
        </div>

        <p className="mt-3 px-1 text-xs text-slate-500 dark:text-slate-400">
          Tip: use Tab to move between rows and Space or Enter to expand.
        </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 →