Web InnoventixFreeCode

File Card

Original · free

A file card with type icon, size and a download action.

byWeb InnoventixReact + Tailwind
cardfilecards
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/card-file.json
card-file.tsx
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type FileKind = "pdf" | "zip" | "image" | "doc" | "sheet" | "video" | "audio" | "code";

type FileItem = {
  id: string;
  name: string;
  kind: FileKind;
  bytes: number;
  meta: string;
};

const FILES: FileItem[] = [
  { id: "f1", name: "Brand-Guidelines-2026.pdf", kind: "pdf", bytes: 4_612_000, meta: "PDF · 24 pages" },
  { id: "f2", name: "Q3-Launch-Assets.zip", kind: "zip", bytes: 268_400_000, meta: "ZIP · 148 files" },
  { id: "f3", name: "hero-render-4k.png", kind: "image", bytes: 12_940_000, meta: "PNG · 3840×2160" },
  { id: "f4", name: "Investor-Memo-Final.docx", kind: "doc", bytes: 842_000, meta: "DOCX · 11 pages" },
  { id: "f5", name: "Revenue-Model-FY26.xlsx", kind: "sheet", bytes: 1_540_000, meta: "XLSX · 9 sheets" },
  { id: "f6", name: "Product-Walkthrough.mp4", kind: "video", bytes: 96_300_000, meta: "MP4 · 3:42" },
  { id: "f7", name: "Podcast-Ep-47-Master.wav", kind: "audio", bytes: 58_100_000, meta: "WAV · 41:08" },
  { id: "f8", name: "auth-service.tar.gz", kind: "code", bytes: 3_205_000, meta: "GZIP · source" },
];

function formatBytes(bytes: number): string {
  const units = ["B", "KB", "MB", "GB"];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  const rounded = value >= 100 || unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
  return `${rounded} ${units[unit]}`;
}

type KindStyle = {
  label: string;
  tint: string;
  ring: string;
  glyph: ReactNode;
};

function kindStyle(kind: FileKind): KindStyle {
  const common = "h-6 w-6";
  switch (kind) {
    case "pdf":
      return {
        label: "PDF",
        tint: "bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-300",
        ring: "group-hover:ring-rose-300/70 dark:group-hover:ring-rose-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <path d="M6 2.75h7.5L18.25 7.5V19a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 19V5A2.25 2.25 0 0 1 6 2.75Z" stroke="currentColor" strokeWidth="1.5" />
            <path d="M13 2.75V7a1 1 0 0 0 1 1h4.25" stroke="currentColor" strokeWidth="1.5" />
            <path d="M7.5 16.5v-3.25h1.1a1 1 0 0 1 0 2H7.5m5-2h1.75m-1.75 0v3.25m0-1.6h1.4m2.35-1.65V16.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        ),
      };
    case "zip":
      return {
        label: "ZIP",
        tint: "bg-amber-100 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300",
        ring: "group-hover:ring-amber-300/70 dark:group-hover:ring-amber-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <path d="M6 2.75h12A2.25 2.25 0 0 1 20.25 5v14A2.25 2.25 0 0 1 18 21.25H6A2.25 2.25 0 0 1 3.75 19V5A2.25 2.25 0 0 1 6 2.75Z" stroke="currentColor" strokeWidth="1.5" />
            <path d="M11.5 3v2m1 1v2m-1 1v2m1 1v2.5a1 1 0 0 1-2 0V13a1 1 0 0 1 2 0Z" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
          </svg>
        ),
      };
    case "image":
      return {
        label: "IMG",
        tint: "bg-violet-100 text-violet-600 dark:bg-violet-500/15 dark:text-violet-300",
        ring: "group-hover:ring-violet-300/70 dark:group-hover:ring-violet-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <rect x="3.75" y="4.75" width="16.5" height="14.5" rx="2.25" stroke="currentColor" strokeWidth="1.5" />
            <circle cx="9" cy="9.5" r="1.5" stroke="currentColor" strokeWidth="1.4" />
            <path d="m4.5 17 4.25-4a1.5 1.5 0 0 1 2 0l3 2.75m2-2.25 3.75 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        ),
      };
    case "doc":
      return {
        label: "DOC",
        tint: "bg-sky-100 text-sky-600 dark:bg-sky-500/15 dark:text-sky-300",
        ring: "group-hover:ring-sky-300/70 dark:group-hover:ring-sky-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <path d="M6 2.75h7.5L18.25 7.5V19a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 19V5A2.25 2.25 0 0 1 6 2.75Z" stroke="currentColor" strokeWidth="1.5" />
            <path d="M13 2.75V7a1 1 0 0 0 1 1h4.25" stroke="currentColor" strokeWidth="1.5" />
            <path d="M7.5 12.5h7M7.5 15.5h7M7.5 18h4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
          </svg>
        ),
      };
    case "sheet":
      return {
        label: "XLS",
        tint: "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
        ring: "group-hover:ring-emerald-300/70 dark:group-hover:ring-emerald-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <rect x="3.75" y="3.75" width="16.5" height="16.5" rx="2.25" stroke="currentColor" strokeWidth="1.5" />
            <path d="M3.75 9h16.5M3.75 14.25h16.5M9.25 9v11.25M14.75 9v11.25" stroke="currentColor" strokeWidth="1.3" />
          </svg>
        ),
      };
    case "video":
      return {
        label: "VID",
        tint: "bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
        ring: "group-hover:ring-indigo-300/70 dark:group-hover:ring-indigo-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <rect x="3.75" y="5.75" width="16.5" height="12.5" rx="2.25" stroke="currentColor" strokeWidth="1.5" />
            <path d="M10.25 9.75 14 12l-3.75 2.25v-4.5Z" fill="currentColor" />
          </svg>
        ),
      };
    case "audio":
      return {
        label: "AUD",
        tint: "bg-fuchsia-100 text-fuchsia-600 dark:bg-fuchsia-500/15 dark:text-fuchsia-300",
        ring: "group-hover:ring-fuchsia-300/70 dark:group-hover:ring-fuchsia-500/40",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <path d="M14.75 4.5v10.4a3 3 0 1 1-1.5-2.6V7.4l-5 1.25v8.6a3 3 0 1 1-1.5-2.6V6.5l8-2Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
          </svg>
        ),
      };
    case "code":
      return {
        label: "SRC",
        tint: "bg-zinc-200 text-zinc-700 dark:bg-zinc-500/20 dark:text-zinc-200",
        ring: "group-hover:ring-zinc-300 dark:group-hover:ring-zinc-500/50",
        glyph: (
          <svg viewBox="0 0 24 24" fill="none" className={common} aria-hidden="true">
            <path d="M9 8.5 5.5 12 9 15.5M15 8.5 18.5 12 15 15.5M13 6l-2 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        ),
      };
  }
}

type Status = "idle" | "downloading" | "done";

function FileCard({ file }: { file: FileItem }) {
  const style = kindStyle(file.kind);
  const [status, setStatus] = useState<Status>("idle");
  const [progress, setProgress] = useState(0);
  const [starred, setStarred] = useState(false);
  const [copied, setCopied] = useState(false);
  const reduce = useReducedMotion();
  const timers = useRef<number[]>([]);

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

  function push(t: number) {
    timers.current.push(t);
  }

  function startDownload() {
    if (status === "downloading") return;
    setStatus("downloading");
    setProgress(0);

    if (reduce) {
      setProgress(100);
      const t = window.setTimeout(() => setStatus("done"), 400);
      push(t);
    } else {
      const steps = [14, 33, 51, 68, 82, 94, 100];
      steps.forEach((value, i) => {
        const t = window.setTimeout(() => {
          setProgress(value);
          if (value === 100) {
            const done = window.setTimeout(() => setStatus("done"), 260);
            push(done);
          }
        }, 190 * (i + 1));
        push(t);
      });
    }

    const reset = window.setTimeout(() => {
      setStatus("idle");
      setProgress(0);
    }, reduce ? 2200 : 3600);
    push(reset);
  }

  async function copyLink() {
    try {
      await navigator.clipboard.writeText(`https://files.northwind.app/d/${file.id}/${file.name}`);
      setCopied(true);
      const t = window.setTimeout(() => setCopied(false), 1600);
      push(t);
    } catch {
      setCopied(false);
    }
  }

  const downloading = status === "downloading";
  const done = status === "done";

  return (
    <div className="group relative flex flex-col rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all duration-300 hover:-translate-y-0.5 hover:border-zinc-300 hover:shadow-lg dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:shadow-black/40">
      <div className="flex items-start gap-3.5">
        <div
          className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl ring-1 ring-transparent transition-all duration-300 ${style.tint} ${style.ring}`}
        >
          {style.glyph}
        </div>

        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100" title={file.name}>
              {file.name}
            </p>
            <span className="shrink-0 rounded-md bg-zinc-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
              {style.label}
            </span>
          </div>
          <p className="mt-1 truncate text-xs text-zinc-500 dark:text-zinc-400">
            {file.meta} · {formatBytes(file.bytes)}
          </p>
        </div>

        <button
          type="button"
          onClick={() => setStarred((s) => !s)}
          aria-pressed={starred}
          aria-label={starred ? `Unstar ${file.name}` : `Star ${file.name}`}
          className="-mr-1 -mt-1 rounded-lg p-1.5 text-zinc-400 transition-colors hover:text-amber-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60 dark:hover:text-amber-400"
        >
          <svg
            viewBox="0 0 24 24"
            className="h-[1.125rem] w-[1.125rem]"
            fill={starred ? "currentColor" : "none"}
            stroke="currentColor"
            strokeWidth="1.6"
            aria-hidden="true"
          >
            <path d="m12 3.5 2.6 5.27 5.82.85-4.21 4.1.99 5.8L12 17.77l-5.2 2.75.99-5.8-4.21-4.1 5.82-.85L12 3.5Z" strokeLinejoin="round" />
          </svg>
        </button>
      </div>

      <div className="mt-4 flex items-center gap-2">
        <button
          type="button"
          onClick={startDownload}
          disabled={downloading}
          aria-label={`Download ${file.name}`}
          className={`relative flex h-9 flex-1 items-center justify-center gap-2 overflow-hidden rounded-lg px-3 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-zinc-900 ${
            done
              ? "bg-emerald-500 text-white focus-visible:ring-emerald-500/60"
              : "bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500/60"
          }`}
        >
          {downloading && (
            <span
              className="absolute inset-y-0 left-0 bg-white/20"
              style={{ width: `${progress}%`, transition: reduce ? "none" : "width 180ms ease-out" }}
              aria-hidden="true"
            />
          )}
          <span className="relative flex items-center gap-2">
            {done ? (
              <>
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden="true">
                  <path d="m5 12.5 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
                Downloaded
              </>
            ) : downloading ? (
              <>
                <svg viewBox="0 0 24 24" className="h-4 w-4 cf-spin" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <path d="M12 3a9 9 0 1 0 9 9" strokeLinecap="round" />
                </svg>
                {progress}%
              </>
            ) : (
              <>
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
                  <path d="M12 3.5v11m0 0 3.75-3.75M12 14.5 8.25 10.75M4.5 17.5v1A2 2 0 0 0 6.5 20.5h11a2 2 0 0 0 2-2v-1" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
                Download
              </>
            )}
          </span>
        </button>

        <button
          type="button"
          onClick={copyLink}
          aria-label={copied ? "Link copied" : `Copy share link for ${file.name}`}
          className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 transition-colors hover:bg-zinc-50 hover:text-zinc-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white"
        >
          <AnimatePresence mode="wait" initial={false}>
            {copied ? (
              <motion.svg
                key="check"
                viewBox="0 0 24 24"
                className="h-4 w-4 text-emerald-500"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                aria-hidden="true"
                initial={reduce ? false : { scale: 0.6, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={reduce ? undefined : { scale: 0.6, opacity: 0 }}
                transition={{ duration: 0.15 }}
              >
                <path d="m5 12.5 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
              </motion.svg>
            ) : (
              <motion.svg
                key="copy"
                viewBox="0 0 24 24"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.7"
                aria-hidden="true"
                initial={reduce ? false : { scale: 0.6, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={reduce ? undefined : { scale: 0.6, opacity: 0 }}
                transition={{ duration: 0.15 }}
              >
                <rect x="9" y="9" width="11" height="11" rx="2" />
                <path d="M15 5.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h.5" />
              </motion.svg>
            )}
          </AnimatePresence>
        </button>
      </div>
    </div>
  );
}

export default function CardFile() {
  return (
    <section className="relative w-full bg-zinc-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes cardfile_spin { to { transform: rotate(360deg); } }
        .cf-spin { animation: cardfile_spin 0.8s linear infinite; transform-origin: center; }
        @media (prefers-reduced-motion: reduce) {
          .cf-spin { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-10 text-center">
          <span className="inline-flex items-center rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
            File library
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
            Shared files
          </h2>
          <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500 dark:text-zinc-400">
            Type-aware cards with size, star, copy link, and a real download flow.
          </p>
        </div>

        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-2">
          {FILES.map((file) => (
            <FileCard key={file.id} file={file} />
          ))}
        </div>
      </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 →