File Upload Dropzone with Animated Progress Ring

A drag and drop upload area with an animated folder illustration, a line stating the accepted types and size limit, and a button that opens the file picker. Dropping or choosing a file switches the area to an uploading view: a set of coloured rings that turn in opposite directions and are revealed clockwise as progress climbs, with the file name, size, percentage and a cancel button. Finished files drop into a list below with a remove button each. Files that are too large or the wrong type show a message that clears by itself. The preview uploads one sample file so the progress view can be seen. It fits a 360 pixel screen.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/file-upload.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, DragEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { Check, FileText, UploadCloud, X } from "lucide-react";

// Rules for what may be uploaded.
const MAX_BYTES = 5 * 1024 * 1024;
const ACCEPTED = ["image/png", "image/jpeg", "image/svg+xml", "application/pdf"];
const ACCEPTED_LABEL = "PNG, JPG, SVG or PDF";
// The upload here is simulated. Replace `startUpload` with a real request and feed its progress in.
const UPLOAD_MS = 2600;
const RING_COLOURS = ["#8b5cf6", "#ec4899", "#06b6d4", "#f59e0b", "#22c55e", "#6366f1", "#f43f5e", "#14b8a6"];

type Item = { name: string; size: number };
type Status = "idle" | "dragging" | "uploading";

const SAMPLE: Item = { name: "brand-guidelines.pdf", size: 3_560_000 };
const ALREADY_UPLOADED: Item[] = [{ name: "logo-final.svg", size: 48_200 }];

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

/** A folder with a bobbing arrow inside a slowly turning dashed circle. */
function Illustration() {
  return (
    <svg viewBox="0 0 100 100" className="h-16 w-16" fill="none" aria-hidden="true">
      <circle className="file-upload-dropzone-turn stroke-violet-200" cx="50" cy="50" r="45" strokeDasharray="4 4" strokeWidth="2" />
      <path className="fill-violet-100 stroke-violet-600" d="M30 35h40c5 0 5 5 5 5v25c0 5-5 5-5 5H30c-5 0-5-5-5-5V40c0-5 5-5 5-5z" strokeWidth="2" />
      <path className="stroke-violet-600" d="M30 35h10c5 0 5-5 10-5s5 5 10 5h10" strokeWidth="2" />
      <g className="file-upload-dropzone-bob stroke-violet-600" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
        <path d="M50 49v14M42 55l8-7 8 7" />
      </g>
    </svg>
  );
}

/** Concentric dashed rings turning both ways, revealed clockwise as the upload progresses. */
function ProgressRings({ progress }: { progress: number }) {
  const length = 2 * Math.PI * 60;
  return (
    <svg viewBox="0 0 240 240" className="h-20 w-20" fill="none" role="img" aria-label={`Upload progress: ${Math.round(progress)} percent`}>
      <defs>
        <mask id="file-upload-dropzone-mask">
          <circle cx="120" cy="120" r="60" stroke="white" strokeWidth="120" strokeDasharray={`${(progress / 100) * length} ${length}`} transform="rotate(-90 120 120)" />
        </mask>
      </defs>
      <circle cx="120" cy="120" r="110" stroke="#ede9fe" strokeWidth="10" />
      <g className="file-upload-dropzone-rings" mask="url(#file-upload-dropzone-mask)" strokeDasharray="18% 40%" strokeWidth="11" strokeLinecap="round">
        {RING_COLOURS.map((colour, i) => (
          <circle key={colour} cx="120" cy="120" r={112 - i * 13} stroke={colour} />
        ))}
      </g>
    </svg>
  );
}

export default function FileUploadDropzoneWithAnimatedProgressRing() {
  const rootRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const inView = useInView(rootRef);
  const reduce = useReducedMotion();
  const [status, setStatus] = useState<Status>("idle");
  const [current, setCurrent] = useState<Item | null>(null);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState("");
  const [uploaded, setUploaded] = useState<Item[]>(ALREADY_UPLOADED);
  const [demo, setDemo] = useState(true);

  // Simulated upload: progress climbs on a timer.
  useEffect(() => {
    if (status !== "uploading") return;
    const timer = setInterval(() => setProgress((value) => Math.min(value + 4, 100)), UPLOAD_MS / 25);
    return () => clearInterval(timer);
  }, [status]);

  // At 100 percent the file joins the list and the drop area returns.
  useEffect(() => {
    if (status !== "uploading" || !current || progress < 100) return;
    setUploaded((list) => [current, ...list.filter((entry) => entry.name !== current.name)].slice(0, 3));
    setCurrent(null);
    setProgress(0);
    setStatus("idle");
  }, [status, current, progress]);

  // Errors clear themselves after a few seconds.
  useEffect(() => {
    if (!error) return;
    const timer = setTimeout(() => setError(""), 3500);
    return () => clearTimeout(timer);
  }, [error]);

  const startUpload = (item: Item) => {
    setError("");
    setCurrent(item);
    setProgress(0);
    setStatus("uploading");
  };

  // Self-playing preview: send one sample file shortly after the card comes into view.
  useEffect(() => {
    if (!demo || !inView || reduce) return;
    const timer = setTimeout(() => {
      setDemo(false);
      startUpload(SAMPLE);
    }, 700);
    return () => clearTimeout(timer);
  }, [demo, inView, reduce]);

  const choose = (file: File | undefined) => {
    setDemo(false);
    if (!file) return;
    if (file.size > MAX_BYTES) return setError(`That file is ${formatBytes(file.size)}. The limit is ${formatBytes(MAX_BYTES)}.`);
    if (!ACCEPTED.includes(file.type)) return setError(`That file type is not accepted. Use ${ACCEPTED_LABEL}.`);
    startUpload({ name: file.name, size: file.size });
  };

  const onDragOver = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    if (status === "idle") setStatus("dragging");
  };
  const onDragLeave = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    if (status === "dragging") setStatus("idle");
  };
  const onDrop = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    if (status === "uploading") return;
    setStatus("idle");
    choose(event.dataTransfer.files?.[0]);
  };
  const onInput = (event: ChangeEvent<HTMLInputElement>) => {
    choose(event.target.files?.[0]);
    event.target.value = "";
  };
  const cancel = () => {
    setDemo(false);
    setCurrent(null);
    setProgress(0);
    setStatus("idle");
  };

  return (
    <div ref={rootRef} className="flex w-full items-center justify-center bg-violet-50 px-4 py-14">
      <style>{`
        @keyframes file-upload-dropzone-cw { to { transform: rotate(360deg); } }
        @keyframes file-upload-dropzone-ccw { to { transform: rotate(-360deg); } }
        @keyframes file-upload-dropzone-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
        .file-upload-dropzone-turn { transform-origin: 50px 50px; animation: file-upload-dropzone-cw 40s linear infinite; }
        .file-upload-dropzone-bob { animation: file-upload-dropzone-bob 2s ease-in-out infinite; }
        .file-upload-dropzone-rings circle { transform-origin: 120px 120px; animation: file-upload-dropzone-cw 6s linear infinite; }
        .file-upload-dropzone-rings circle:nth-child(2n) { animation-name: file-upload-dropzone-ccw; animation-duration: 7.5s; }
        .file-upload-dropzone-rings circle:nth-child(3n) { animation-duration: 9s; }
        @media (prefers-reduced-motion: reduce) {
          .file-upload-dropzone-turn, .file-upload-dropzone-bob, .file-upload-dropzone-rings circle { animation: none; }
        }
      `}</style>

      <section className="w-full max-w-sm" aria-labelledby="file-upload-dropzone-title">
        <h2 id="file-upload-dropzone-title" className="text-lg font-semibold text-zinc-900">
          Brand assets
        </h2>
        <p className="mt-1 text-sm text-zinc-600">Add the files your designer needs to get started.</p>

        <div className="mt-4 rounded-2xl bg-white p-1.5 shadow-xl shadow-violet-900/10 ring-1 ring-violet-200">
          <div
            onDragOver={onDragOver}
            onDragLeave={onDragLeave}
            onDrop={onDrop}
            className={`relative h-[250px] overflow-hidden rounded-xl border-2 border-dashed transition-colors duration-300 ${error ? "border-red-400 bg-red-50/50" : status === "dragging" ? "border-violet-500 bg-violet-50" : "border-violet-200 bg-white"}`}
          >
            <AnimatePresence mode="wait" initial={false}>
              {status === "uploading" && current ? (
                <motion.div
                  key="uploading"
                  className="absolute inset-0 flex flex-col items-center justify-center p-6"
                  initial={reduce ? false : { opacity: 0, scale: 0.95 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={reduce ? undefined : { opacity: 0, scale: 0.95 }}
                  transition={{ duration: 0.2 }}
                >
                  <ProgressRings progress={progress} />
                  <p className="mt-4 max-w-full truncate text-sm font-semibold text-zinc-900">{current.name}</p>
                  <p className="mt-1 flex items-center gap-2 text-xs">
                    <span className="text-zinc-600">{formatBytes(current.size)}</span>
                    <span className="font-semibold tabular-nums text-violet-700">{Math.round(progress)}%</span>
                  </p>
                  <button
                    type="button"
                    onClick={cancel}
                    className="mt-4 inline-flex h-10 w-4/5 items-center justify-center rounded-lg bg-zinc-100 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2"
                  >
                    Cancel
                  </button>
                </motion.div>
              ) : (
                <motion.div
                  key="dropzone"
                  className="absolute inset-0 flex flex-col items-center justify-center p-6"
                  initial={reduce ? false : { opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0, scale: status === "dragging" ? 0.97 : 1 }}
                  exit={reduce ? undefined : { opacity: 0, y: -10 }}
                  transition={{ duration: 0.2 }}
                >
                  <Illustration />
                  <p className="mt-3 text-base font-semibold tracking-tight text-zinc-900">{status === "dragging" ? "Drop it here" : "Drag a file here"}</p>
                  <p className="mt-1 text-xs text-zinc-600">
                    {ACCEPTED_LABEL}, up to {formatBytes(MAX_BYTES)}
                  </p>
                  <button
                    type="button"
                    onClick={() => inputRef.current?.click()}
                    className="mt-4 inline-flex h-10 w-4/5 items-center justify-center gap-2 rounded-lg bg-violet-600 text-sm font-semibold text-white transition hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2"
                  >
                    Choose a file
                    <UploadCloud className="h-4 w-4" aria-hidden="true" />
                  </button>
                  <input ref={inputRef} type="file" accept={ACCEPTED.join(",")} onChange={onInput} className="sr-only" aria-label="Choose a file to upload" tabIndex={-1} />
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        <p className="mt-2 min-h-[1.25rem] text-sm font-medium text-red-700" role="alert">
          {error}
        </p>

        <ul className="space-y-2" aria-label="Uploaded files">
          <AnimatePresence initial={false}>
            {uploaded.map((item) => (
              <motion.li
                key={item.name}
                layout={!reduce}
                initial={reduce ? false : { opacity: 0, y: -12, scale: 0.96 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, scale: 0.96 }}
                transition={{ type: "spring", stiffness: 320, damping: 26 }}
                className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 ring-1 ring-violet-200"
              >
                <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-100 text-violet-700" aria-hidden="true">
                  <FileText className="h-4 w-4" />
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium text-zinc-900">{item.name}</span>
                  <span className="flex items-center gap-1 text-xs text-zinc-600">
                    <Check className="h-3 w-3 text-emerald-600" aria-hidden="true" />
                    {formatBytes(item.size)}, uploaded
                  </span>
                </span>
                <button
                  type="button"
                  aria-label={`Remove ${item.name}`}
                  onClick={() => setUploaded((list) => list.filter((entry) => entry.name !== item.name))}
                  className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-600"
                >
                  <X className="h-4 w-4" aria-hidden="true" />
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>
      </section>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/file-upload-dropzone-with-animated-progress-ring.json

When to use it

How to customize it

Accessibility

Questions

How does drag and drop work in the browser?

The drop area handles three events. `dragover` must call `preventDefault` or the browser will not allow a drop. `dragleave` resets the highlight. `drop` reads the files from `event.dataTransfer.files`.

How is the file checked before upload?

The `choose` function compares `file.size` with the limit and looks up `file.type` in the accepted list. Either failure sets an error message and stops. Check again on the server, because both values can be faked.

How does the ring reveal follow progress?

The coloured rings are drawn in full and hidden by an SVG mask. The mask is a very thick circle stroke with a dash as long as the progress, which makes a pie slice that sweeps clockwise from the top.

Can it take several files at once?

This version sends one file at a time. To allow more, add `multiple` to the input, loop over the files in `choose` and keep a progress value per file.

Does cancel stop a real upload?

It will once you connect one. Keep the request's abort handle in a ref and call it inside `cancel` before the state is reset.

Adapted from File Upload in Kokonut UI, used under its open-source licence. Licences and credits

Related: Contact Form with Topic Select and Details Panel, Multi-Step Signup Form with Step Indicator, Six CSS Loaders: Orbit, Bars, Morph, Ring, Pulse and Shimmer.

More forms or all free UI components. Start for free to have a full site built around it.

More in Forms

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap