Web InnoventixFreeCode

File Manager Storage

Original · free

storage usage widget

byWeb InnoventixReact + Tailwind
filemgrstoragefilemanager
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/filemgr-storage.json
filemgr-storage.tsx
"use client";

import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type Category = {
  id: string;
  label: string;
  hint: string;
  bytes: number;
  stroke: string;
  dot: string;
  bar: string;
};

type Plan = {
  id: string;
  name: string;
  quota: number;
  quotaLabel: string;
  seats: string;
};

type ReclaimItem = {
  id: string;
  label: string;
  hint: string;
  bytes: number;
};

const GB = 1_000_000_000;

const CATEGORIES: Category[] = [
  {
    id: "video",
    label: "Video & renders",
    hint: "1,842 files · ProRes masters, launch cuts",
    bytes: 612.4 * GB,
    stroke: "stroke-rose-500 dark:stroke-rose-400",
    dot: "bg-rose-500 dark:bg-rose-400",
    bar: "bg-rose-500 dark:bg-rose-400",
  },
  {
    id: "photos",
    label: "Photo library",
    hint: "24,106 files · RAW shoots, product stills",
    bytes: 214.8 * GB,
    stroke: "stroke-violet-500 dark:stroke-violet-400",
    dot: "bg-violet-500 dark:bg-violet-400",
    bar: "bg-violet-500 dark:bg-violet-400",
  },
  {
    id: "design",
    label: "Design files",
    hint: "3,290 files · Figma exports, 3D scenes",
    bytes: 96.3 * GB,
    stroke: "stroke-indigo-500 dark:stroke-indigo-400",
    dot: "bg-indigo-500 dark:bg-indigo-400",
    bar: "bg-indigo-500 dark:bg-indigo-400",
  },
  {
    id: "docs",
    label: "Documents",
    hint: "11,478 files · Contracts, decks, models",
    bytes: 38.5 * GB,
    stroke: "stroke-emerald-500 dark:stroke-emerald-400",
    dot: "bg-emerald-500 dark:bg-emerald-400",
    bar: "bg-emerald-500 dark:bg-emerald-400",
  },
  {
    id: "code",
    label: "Code & archives",
    hint: "612 files · Repo mirrors, nightly backups",
    bytes: 21.7 * GB,
    stroke: "stroke-sky-500 dark:stroke-sky-400",
    dot: "bg-sky-500 dark:bg-sky-400",
    bar: "bg-sky-500 dark:bg-sky-400",
  },
  {
    id: "reclaim",
    label: "Trash & old versions",
    hint: "Safe to clear — nothing here is live",
    bytes: 0,
    stroke: "stroke-amber-500 dark:stroke-amber-400",
    dot: "bg-amber-500 dark:bg-amber-400",
    bar: "bg-amber-500 dark:bg-amber-400",
  },
];

const PLANS: Plan[] = [
  { id: "starter", name: "Starter", quota: 200 * GB, quotaLabel: "200 GB", seats: "3 seats" },
  { id: "studio", name: "Studio", quota: 2000 * GB, quotaLabel: "2 TB", seats: "12 seats" },
  { id: "scale", name: "Scale", quota: 8000 * GB, quotaLabel: "8 TB", seats: "40 seats" },
];

const RECLAIM: ReclaimItem[] = [
  { id: "rc-trash", label: "Trash", hint: "1,204 files deleted 30+ days ago", bytes: 24.6 * GB },
  { id: "rc-versions", label: "Previous file versions", hint: "3,918 revisions older than 90 days", bytes: 61.2 * GB },
  { id: "rc-dupes", label: "Duplicate uploads", hint: "142 byte-identical copies", bytes: 8.9 * GB },
];

const RADIUS = 64;
const CIRC = 2 * Math.PI * RADIUS;
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];

const UNITS = ["B", "KB", "MB", "GB", "TB"];

function formatSize(bytes: number): string {
  if (bytes <= 0) return "0 GB";
  let value = bytes;
  let unit = 0;
  while (value >= 1000 && unit < UNITS.length - 1) {
    value /= 1000;
    unit += 1;
  }
  const digits = unit >= 4 ? 2 : unit === 3 ? 1 : 0;
  return `${value.toFixed(digits)} ${UNITS[unit]}`;
}

function formatPercent(value: number): string {
  if (value <= 0) return "0";
  if (value < 1) return value.toFixed(1);
  return String(Math.round(value));
}

function AlertIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
      <path d="M10.7 4.2 2.9 17.5A1.5 1.5 0 0 0 4.2 19.8h15.6a1.5 1.5 0 0 0 1.3-2.3L13.3 4.2a1.5 1.5 0 0 0-2.6 0Z" />
      <path d="M12 9.5v4" />
      <path d="M12 16.6h.01" />
    </svg>
  );
}

function SweepIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
      <path d="M14.5 3.5 20 9" />
      <path d="M13 5 6.6 11.4a2 2 0 0 0 0 2.83l3.17 3.17a2 2 0 0 0 2.83 0L19 11" />
      <path d="M4 21h7" />
      <path d="M8.5 14.5 5 18" />
    </svg>
  );
}

function UndoIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
      <path d="M4 8h10a5.5 5.5 0 0 1 0 11h-4" />
      <path d="m7.5 4.5-3.5 3.5 3.5 3.5" />
    </svg>
  );
}

function DriveIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
      <rect x="2.5" y="13" width="19" height="7" rx="2" />
      <path d="M5.4 13 8 4.6A1.5 1.5 0 0 1 9.4 3.5h5.2A1.5 1.5 0 0 1 16 4.6L18.6 13" />
      <path d="M6.5 16.5h.01" />
      <path d="M10 16.5h.01" />
    </svg>
  );
}

export default function FileManagerStorage() {
  const reduce = useReducedMotion();
  const [planId, setPlanId] = useState("studio");
  const [excluded, setExcluded] = useState<Set<string>>(() => new Set<string>());
  const [picked, setPicked] = useState<Set<string>>(() => new Set<string>());
  const [cleared, setCleared] = useState<Set<string>>(() => new Set<string>());
  const [highlight, setHighlight] = useState<string | null>(null);
  const [status, setStatus] = useState("Studio plan · 1.08 TB of 2 TB used");
  const planRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const plan = useMemo(() => PLANS.find((p) => p.id === planId) ?? PLANS[1], [planId]);

  const sizeOf = useCallback(
    (category: Category) =>
      category.id === "reclaim"
        ? RECLAIM.filter((r) => !cleared.has(r.id)).reduce((sum, r) => sum + r.bytes, 0)
        : category.bytes,
    [cleared],
  );

  const used = useMemo(
    () => CATEGORIES.filter((c) => !excluded.has(c.id)).reduce((sum, c) => sum + sizeOf(c), 0),
    [excluded, sizeOf],
  );

  const over = used > plan.quota;
  const denom = Math.max(plan.quota, used, 1);
  const percent = (used / plan.quota) * 100;

  const segments = useMemo(() => {
    const list: Array<{ id: string; stroke: string; drawn: number; offset: number }> = [];
    let cursor = 0;
    for (const category of CATEGORIES) {
      const bytes = excluded.has(category.id) ? 0 : sizeOf(category);
      const length = (bytes / denom) * CIRC;
      list.push({
        id: category.id,
        stroke: category.stroke,
        drawn: length > 6 ? length - 3 : length,
        offset: cursor,
      });
      cursor += length;
    }
    return list;
  }, [denom, excluded, sizeOf]);

  const pickedBytes = useMemo(
    () => RECLAIM.filter((r) => picked.has(r.id) && !cleared.has(r.id)).reduce((sum, r) => sum + r.bytes, 0),
    [cleared, picked],
  );

  const selectPlan = useCallback(
    (next: Plan) => {
      setPlanId(next.id);
      setStatus(`${next.name} plan selected · ${next.quotaLabel} quota, ${next.seats}`);
    },
    [],
  );

  const onPlanKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const index = PLANS.findIndex((p) => p.id === planId);
      let next: number | null = null;
      if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % PLANS.length;
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + PLANS.length) % PLANS.length;
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = PLANS.length - 1;
      if (next === null) return;
      event.preventDefault();
      selectPlan(PLANS[next]);
      planRefs.current[next]?.focus();
    },
    [planId, selectPlan],
  );

  const toggleCategory = useCallback((category: Category) => {
    setExcluded((prev) => {
      const next = new Set(prev);
      if (next.has(category.id)) {
        next.delete(category.id);
        setStatus(`${category.label} included in the usage total`);
      } else {
        next.add(category.id);
        setStatus(`${category.label} excluded from the usage total`);
      }
      return next;
    });
  }, []);

  const togglePick = useCallback((item: ReclaimItem) => {
    setPicked((prev) => {
      const next = new Set(prev);
      if (next.has(item.id)) next.delete(item.id);
      else next.add(item.id);
      return next;
    });
  }, []);

  const freeUp = useCallback(() => {
    if (pickedBytes <= 0) return;
    const freed = formatSize(pickedBytes);
    setCleared((prev) => {
      const next = new Set(prev);
      picked.forEach((id) => next.add(id));
      return next;
    });
    setPicked(new Set());
    setStatus(`${freed} freed up — cleared items are recoverable for 7 days`);
  }, [picked, pickedBytes]);

  const restore = useCallback(() => {
    setCleared(new Set());
    setPicked(new Set());
    setStatus("Restored all cleared items to the usage total");
  }, []);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes fmstor-ring-in {
          from { opacity: 0; transform: rotate(-18deg) scale(.92); }
          to { opacity: 1; transform: none; }
        }
        @keyframes fmstor-alert-pulse {
          0%, 100% { opacity: 1; }
          50% { opacity: .45; }
        }
        .fmstor-ring { animation: fmstor-ring-in .7s cubic-bezier(.22,1,.36,1) backwards; transform-origin: 50% 50%; }
        .fmstor-seg {
          transition: stroke-dasharray .6s cubic-bezier(.22,1,.36,1), stroke-dashoffset .6s cubic-bezier(.22,1,.36,1), opacity .2s ease, stroke-width .2s ease;
        }
        .fmstor-alert-dot { animation: fmstor-alert-pulse 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .fmstor-ring, .fmstor-alert-dot { animation: none !important; }
          .fmstor-seg { transition: opacity .2s ease !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-8 flex flex-col gap-4 border-b border-slate-200 pb-6 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800">
          <div>
            <p className="mb-2 inline-flex items-center gap-1.5 rounded-full bg-slate-200/70 px-2.5 py-1 text-[11px] font-semibold tracking-wide text-slate-600 uppercase dark:bg-slate-800 dark:text-slate-300">
              <DriveIcon />
              Atlas Studio · Shared workspace
            </p>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Storage usage</h2>
            <p className="mt-1.5 max-w-md text-sm text-slate-600 dark:text-slate-400">
              Last scanned 17 July, 06:40 IST. Switch plans to model your headroom, or clear the safe-to-delete buckets.
            </p>
          </div>
          <dl className="flex shrink-0 gap-6">
            <div>
              <dt className="text-[11px] font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400">Files</dt>
              <dd className="mt-0.5 text-lg font-semibold tabular-nums">41,328</dd>
            </div>
            <div>
              <dt className="text-[11px] font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400">Growth / mo</dt>
              <dd className="mt-0.5 text-lg font-semibold tabular-nums text-amber-600 dark:text-amber-400">+84 GB</dd>
            </div>
          </dl>
        </header>

        <div className="grid gap-5 lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)]">
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 14 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}
            className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
          >
            <div className="relative mx-auto h-[200px] w-[200px]">
              <svg viewBox="0 0 160 160" className="fmstor-ring h-full w-full" role="img" aria-label={`${formatPercent(percent)} percent of the ${plan.quotaLabel} ${plan.name} plan used`}>
                <g transform="rotate(-90 80 80)">
                  <circle
                    cx="80"
                    cy="80"
                    r={RADIUS}
                    fill="none"
                    strokeWidth={13}
                    className={over ? "stroke-rose-200 dark:stroke-rose-500/25" : "stroke-slate-200 dark:stroke-slate-800"}
                  />
                  {segments.map((segment) => (
                    <circle
                      key={segment.id}
                      cx="80"
                      cy="80"
                      r={RADIUS}
                      fill="none"
                      strokeLinecap="butt"
                      strokeWidth={highlight === segment.id ? 17 : 13}
                      strokeDasharray={`${segment.drawn} ${Math.max(CIRC - segment.drawn, 0)}`}
                      strokeDashoffset={-segment.offset}
                      className={`fmstor-seg ${segment.stroke}`}
                      style={{ opacity: highlight && highlight !== segment.id ? 0.2 : 1 }}
                    />
                  ))}
                </g>
              </svg>
              <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
                <span className={`text-4xl font-semibold tracking-tight tabular-nums ${over ? "text-rose-600 dark:text-rose-400" : ""}`}>
                  {formatPercent(percent)}
                  <span className="text-xl">%</span>
                </span>
                <span className="mt-1 text-sm font-medium tabular-nums">{formatSize(used)}</span>
                <span className="text-xs text-slate-500 dark:text-slate-400">of {plan.quotaLabel}</span>
              </div>
            </div>

            {over ? (
              <p className="mt-5 flex items-start gap-2 rounded-xl bg-rose-50 px-3 py-2.5 text-xs leading-relaxed text-rose-700 dark:bg-rose-500/10 dark:text-rose-300">
                <span className="fmstor-alert-dot mt-0.5 shrink-0">
                  <AlertIcon />
                </span>
                <span>
                  Over the {plan.quotaLabel} quota by <strong className="font-semibold tabular-nums">{formatSize(used - plan.quota)}</strong>. New uploads and sync would be paused on this plan.
                </span>
              </p>
            ) : (
              <p className="mt-5 rounded-xl bg-slate-100 px-3 py-2.5 text-xs leading-relaxed text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
                <strong className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">{formatSize(plan.quota - used)}</strong> free — about{" "}
                <strong className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                  {Math.max(Math.floor((plan.quota - used) / (84 * GB)), 0)} months
                </strong>{" "}
                at your current growth rate.
              </p>
            )}

            <div className="mt-6">
              <p id="fmstor-plan-label" className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:text-slate-400">
                Model a plan
              </p>
              <div role="radiogroup" aria-labelledby="fmstor-plan-label" onKeyDown={onPlanKeyDown} className="grid grid-cols-3 gap-2">
                {PLANS.map((item, index) => {
                  const active = item.id === planId;
                  return (
                    <button
                      key={item.id}
                      ref={(node) => {
                        planRefs.current[index] = node;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => selectPlan(item)}
                      className={`rounded-xl border px-2 py-2.5 text-center transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:focus-visible:ring-offset-slate-900 ${
                        active
                          ? "border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-500/15 dark:text-indigo-200"
                          : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
                      }`}
                    >
                      <span className="block text-xs font-semibold">{item.name}</span>
                      <span className="mt-0.5 block text-[11px] tabular-nums opacity-80">{item.quotaLabel}</span>
                    </button>
                  );
                })}
              </div>
            </div>
          </motion.div>

          <motion.div
            initial={reduce ? false : { opacity: 0, y: 14 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: reduce ? 0 : 0.45, delay: reduce ? 0 : 0.08, ease: EASE }}
            className="flex flex-col gap-5"
          >
            <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
              <div className="mb-4 flex items-baseline justify-between gap-3">
                <h3 className="text-sm font-semibold">Breakdown by type</h3>
                <p className="text-xs text-slate-500 dark:text-slate-400">Toggle a row to include or exclude it</p>
              </div>
              <ul className="flex flex-col gap-1">
                {CATEGORIES.map((category) => {
                  const bytes = sizeOf(category);
                  const active = !excluded.has(category.id);
                  const share = active ? (bytes / denom) * 100 : 0;
                  return (
                    <li key={category.id}>
                      <button
                        type="button"
                        role="switch"
                        aria-checked={active}
                        aria-label={`${category.label}, ${formatSize(bytes)}. ${active ? "Included in" : "Excluded from"} the usage total.`}
                        onClick={() => toggleCategory(category)}
                        onMouseEnter={() => setHighlight(category.id)}
                        onMouseLeave={() => setHighlight(null)}
                        onFocus={() => setHighlight(category.id)}
                        onBlur={() => setHighlight(null)}
                        className="w-full rounded-xl px-2.5 py-2.5 text-left transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none dark:hover:bg-slate-800/60"
                      >
                        <span className="flex items-center gap-2.5">
                          <span
                            aria-hidden="true"
                            className={`h-2.5 w-2.5 shrink-0 rounded-full transition-opacity ${category.dot} ${active ? "opacity-100" : "opacity-25"}`}
                          />
                          <span className={`min-w-0 flex-1 transition-opacity ${active ? "opacity-100" : "opacity-45"}`}>
                            <span className="block truncate text-sm font-medium">{category.label}</span>
                            <span className="block truncate text-xs text-slate-500 dark:text-slate-400">{category.hint}</span>
                          </span>
                          <span className={`shrink-0 text-right transition-opacity ${active ? "opacity-100" : "opacity-45"}`}>
                            <span className="block text-sm font-semibold tabular-nums">{formatSize(bytes)}</span>
                            <span className="block text-xs tabular-nums text-slate-500 dark:text-slate-400">{formatPercent(share)}%</span>
                          </span>
                        </span>
                        <span aria-hidden="true" className="mt-2 block h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                          <motion.span
                            className={`block h-full rounded-full ${category.bar}`}
                            initial={false}
                            animate={{ width: `${Math.max(share, 0)}%` }}
                            transition={{ duration: reduce ? 0 : 0.5, ease: EASE }}
                          />
                        </span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>

            <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
              <div className="mb-3 flex items-baseline justify-between gap-3">
                <h3 className="text-sm font-semibold">Reclaim space</h3>
                {cleared.size > 0 ? (
                  <button
                    type="button"
                    onClick={restore}
                    className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none dark:text-indigo-300 dark:hover:bg-indigo-500/15"
                  >
                    <UndoIcon />
                    Undo
                  </button>
                ) : null}
              </div>
              <ul className="flex flex-col divide-y divide-slate-100 dark:divide-slate-800">
                {RECLAIM.map((item) => {
                  const done = cleared.has(item.id);
                  return (
                    <li key={item.id}>
                      <label
                        className={`flex cursor-pointer items-center gap-3 py-2.5 ${done ? "cursor-default opacity-45" : ""}`}
                      >
                        <input
                          type="checkbox"
                          checked={picked.has(item.id) && !done}
                          disabled={done}
                          onChange={() => togglePick(item)}
                          className="h-4 w-4 shrink-0 rounded border-slate-300 accent-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:border-slate-600 dark:focus-visible:ring-offset-slate-900"
                        />
                        <span className="min-w-0 flex-1">
                          <span className={`block truncate text-sm font-medium ${done ? "line-through" : ""}`}>{item.label}</span>
                          <span className="block truncate text-xs text-slate-500 dark:text-slate-400">
                            {done ? "Cleared · recoverable for 7 days" : item.hint}
                          </span>
                        </span>
                        <span className="shrink-0 text-sm font-semibold tabular-nums">{formatSize(item.bytes)}</span>
                      </label>
                    </li>
                  );
                })}
              </ul>
              <button
                type="button"
                onClick={freeUp}
                disabled={pickedBytes <= 0}
                className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-500"
              >
                <SweepIcon />
                {pickedBytes > 0 ? `Free up ${formatSize(pickedBytes)}` : "Select items to free up"}
              </button>
            </div>
          </motion.div>
        </div>

        <p
          role="status"
          aria-live="polite"
          className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-xs text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"
        >
          {status}
        </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 →