# Hopper — Animated Input for React

> A file-upload dropzone over a faint coordinate grid: dragging a file over it lifts the zone and brightens the grid, and dropped files seat in as stacked cards showing name, size, and type — each removable by pointer or keyboard. Backed by a real hidden file input with type, size, and count validation; the zone opens the native picker on click, Enter, or Space.

- Page: https://crucibleui.com/components/inputs/hopper
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/hopper
```

Registry JSON: https://crucibleui.com/r/hopper.json

Import from `@/components/crucible/hopper`.

## Props (`HopperProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `accept?` | `string` | `undefined — any file` | Native `accept` string, honoured for BOTH the picker and drag-drop (drops bypass the input's own filtering, so it is re-checked here). e.g. "image/*,.pdf". |
| `multiple?` | `boolean` | `true` | Allow more than one file. When false, a new file replaces the last. |
| `maxSize?` | `number` | `undefined — no limit` | Max bytes per file; larger files are rejected. |
| `maxCount?` | `number` | `undefined — no limit` | Max number of files held at once; extras are rejected. |
| `onChange?` | `(files: File[]) => void` | — | Fires whenever the held set changes, with the native files in order. |
| `onReject?` | `(rejections: HopperRejection[]) => void` | — | Fires with any files turned away (wrong type / too large / over count). |
| `label?` | `React.ReactNode` | `"Drop files or click to browse"` | Headline inside the zone. |
| `hint?` | `React.ReactNode` | — | Sub-line under the headline; defaults to a generated accept/size summary. |
| `renderCard?` | `(args: { file: File; id: string; index: number; remove: () => void; }) => React.ReactNode` | — | Per-file card slot — replaces the default card body (the surrounding focusable `<li>` with Delete-to-remove is always kept for a11y). |
| `renderProgress?` | `(args: { file: File; id: string }) => React.ReactNode` | — | Progress slot rendered inside each default card, e.g. an upload bar. |
| `accent?` | `string` | `sky` | The single cool accent for the active drag border/glow. |
| `disabled?` | `boolean` | `false` | Disable the whole control. |
| `paused?` | `boolean` | `false` | Freeze motion (grid brighten, card spring). |
| `className?` | `string` | — | — |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Hopper } from "@/components/crucible/hopper";

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function HopperDemo() {
  const [count, setCount] = React.useState(0);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-md flex-col gap-3 px-6 py-8">
        <span className={LABEL}>Upload</span>
        <Hopper
          accept="image/*,.pdf"
          maxSize={5 * 1024 * 1024}
          maxCount={5}
          onChange={(files) => setCount(files.length)}
        />
        <p className="text-sm text-neutral-500">
          Drag a file over the zone — it lifts and the grid brightens. Drop it to
          seat a card. Focus a card and press Delete to remove it.
        </p>
        <span className="h-5 text-xs text-neutral-600">
          {count > 0 ? (
            <>
              Holding <span className="text-neutral-300">{count}</span>{" "}
              file{count === 1 ? "" : "s"}
            </>
          ) : (
            "No files yet"
          )}
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`hopper.tsx`)

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/** A file the dropzone is currently holding, with a stable id for keys/focus. */
export interface HopperFile {
  /** Stable, deterministic id assigned when the file was accepted. */
  id: string;
  /** The underlying native File object. */
  file: File;
}

/** Why a file was turned away, surfaced to `onReject`. */
export type HopperRejectReason = "type" | "size" | "count";

export interface HopperRejection {
  file: File;
  reason: HopperRejectReason;
}

export interface HopperProps {
  /**
   * Native `accept` string, honoured for BOTH the picker and drag-drop
   * (drops bypass the input's own filtering, so it is re-checked here).
   * e.g. "image/*,.pdf". @default undefined — any file
   */
  accept?: string;
  /** Allow more than one file. When false, a new file replaces the last. @default true */
  multiple?: boolean;
  /** Max bytes per file; larger files are rejected. @default undefined — no limit */
  maxSize?: number;
  /** Max number of files held at once; extras are rejected. @default undefined — no limit */
  maxCount?: number;
  /** Fires whenever the held set changes, with the native files in order. */
  onChange?: (files: File[]) => void;
  /** Fires with any files turned away (wrong type / too large / over count). */
  onReject?: (rejections: HopperRejection[]) => void;
  /** Headline inside the zone. @default "Drop files or click to browse" */
  label?: React.ReactNode;
  /** Sub-line under the headline; defaults to a generated accept/size summary. */
  hint?: React.ReactNode;
  /**
   * Per-file card slot — replaces the default card body (the surrounding
   * focusable `<li>` with Delete-to-remove is always kept for a11y).
   */
  renderCard?: (args: {
    file: File;
    id: string;
    index: number;
    remove: () => void;
  }) => React.ReactNode;
  /** Progress slot rendered inside each default card, e.g. an upload bar. */
  renderProgress?: (args: { file: File; id: string }) => React.ReactNode;
  /** The single cool accent for the active drag border/glow. @default sky */
  accent?: string;
  /** Disable the whole control. @default false */
  disabled?: boolean;
  /** Freeze motion (grid brighten, card spring). @default false */
  paused?: boolean;
  className?: string;
}

const DEFAULT_ACCENT = "#38bdf8";

/** Deterministic, monotonic id — no Math.random (SSR/hydration safe). */
function useIdFactory() {
  const counter = React.useRef(0);
  return React.useCallback(() => `hopper-file-${(counter.current += 1)}`, []);
}

function formatBytes(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const k = 1024;
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1);
  const v = bytes / Math.pow(k, i);
  return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
}

function extensionOf(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toUpperCase() : "FILE";
}

/** Does a file satisfy an `accept` token list? Empty accept = anything. */
function acceptMatches(file: File, accept?: string): boolean {
  if (!accept || !accept.trim()) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept
    .split(",")
    .map((t) => t.trim().toLowerCase())
    .filter(Boolean)
    .some((token) => {
      if (token.startsWith(".")) return name.endsWith(token);
      if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
      return type === token;
    });
}

/**
 * Hopper — a file-upload dropzone over a faint coordinate grid. Dragging a file
 * over the zone lifts it and brightens the grid; on drop it settles and the
 * files seat in as stacked cards (name / size / type) that each carry a soft
 * downward inner-shadow. Cards are removable by pointer or keyboard (focus a
 * card, press Delete). Clicking the zone opens the native picker via a real
 * hidden `<input type="file">`; the zone is a button, so Enter/Space open it
 * too. Types, per-file size, and total count are validated for drops and picks
 * alike, with turned-away files reported to `onReject`. Reduced motion drops the
 * lift and card spring and holds the grid static — cards appear instantly.
 */
export function Hopper({
  accept,
  multiple = true,
  maxSize,
  maxCount,
  onChange,
  onReject,
  label = "Drop files or click to browse",
  hint,
  renderCard,
  renderProgress,
  accent = DEFAULT_ACCENT,
  disabled = false,
  paused = false,
  className,
}: HopperProps) {
  const reducedMotion = useReducedMotion();
  const still = reducedMotion || paused;

  const inputRef = React.useRef<HTMLInputElement>(null);
  const buttonRef = React.useRef<HTMLButtonElement>(null);
  const cardRefs = React.useRef(new Map<string, HTMLLIElement>());
  const pendingFocus = React.useRef<string | "zone" | null>(null);
  const dragDepth = React.useRef(0);
  const nextId = useIdFactory();

  const [files, setFiles] = React.useState<HopperFile[]>([]);
  const [dragActive, setDragActive] = React.useState(false);

  const emit = React.useCallback(
    (next: HopperFile[]) => {
      setFiles(next);
      onChange?.(next.map((f) => f.file));
    },
    [onChange]
  );

  const ingest = React.useCallback(
    (list: FileList | File[]) => {
      if (disabled) return;
      const incoming = Array.from(list);
      if (incoming.length === 0) return;

      const rejected: HopperRejection[] = [];
      const accepted: File[] = [];
      for (const file of incoming) {
        if (!acceptMatches(file, accept)) {
          rejected.push({ file, reason: "type" });
        } else if (maxSize != null && file.size > maxSize) {
          rejected.push({ file, reason: "size" });
        } else {
          accepted.push(file);
        }
      }

      if (!multiple) {
        if (accepted.length > 0) {
          // Keep the last valid file; report the rest as count overflow.
          accepted.slice(0, -1).forEach((file) => rejected.push({ file, reason: "count" }));
          emit([{ id: nextId(), file: accepted[accepted.length - 1] }]);
        }
      } else {
        const room = maxCount != null ? Math.max(0, maxCount - files.length) : Infinity;
        const toAdd = accepted.slice(0, room);
        accepted.slice(room).forEach((file) => rejected.push({ file, reason: "count" }));
        if (toAdd.length > 0) {
          emit([...files, ...toAdd.map((file) => ({ id: nextId(), file }))]);
        }
      }

      if (rejected.length > 0) onReject?.(rejected);
    },
    [accept, disabled, emit, files, maxCount, maxSize, multiple, nextId, onReject]
  );

  const remove = React.useCallback(
    (id: string) => {
      const idx = files.findIndex((f) => f.id === id);
      if (idx === -1) return;
      // Decide where focus should land after the card leaves.
      const nextTarget = files[idx + 1]?.id ?? files[idx - 1]?.id ?? "zone";
      pendingFocus.current = nextTarget;
      cardRefs.current.delete(id);
      emit(files.filter((f) => f.id !== id));
    },
    [emit, files]
  );

  // After a removal, move focus to the adjacent card (or back to the zone).
  React.useEffect(() => {
    const target = pendingFocus.current;
    if (!target) return;
    pendingFocus.current = null;
    if (target === "zone") buttonRef.current?.focus();
    else cardRefs.current.get(target)?.focus();
  }, [files]);

  const openPicker = React.useCallback(() => {
    if (disabled) return;
    inputRef.current?.click();
  }, [disabled]);

  const onDragOver = (e: React.DragEvent) => {
    if (disabled) return;
    e.preventDefault();
    if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
  };
  const onDragEnter = (e: React.DragEvent) => {
    if (disabled) return;
    e.preventDefault();
    dragDepth.current += 1;
    if (dragDepth.current === 1) setDragActive(true);
  };
  const onDragLeave = (e: React.DragEvent) => {
    if (disabled) return;
    e.preventDefault();
    dragDepth.current = Math.max(0, dragDepth.current - 1);
    if (dragDepth.current === 0) setDragActive(false);
  };
  const onDrop = (e: React.DragEvent) => {
    if (disabled) return;
    e.preventDefault();
    dragDepth.current = 0;
    setDragActive(false);
    if (e.dataTransfer?.files?.length) ingest(e.dataTransfer.files);
  };

  const defaultHint =
    hint ??
    [
      accept ? accept.replace(/,/g, ", ") : "Any file type",
      maxSize != null ? `up to ${formatBytes(maxSize)} each` : null,
      maxCount != null ? `max ${maxCount}` : multiple ? null : "single file",
    ]
      .filter(Boolean)
      .join(" · ");

  const cardTransition = still
    ? { duration: 0 }
    : ({ type: "spring", stiffness: 520, damping: 34 } as const);

  return (
    <div
      data-crucible="hopper"
      className={cn("flex w-full max-w-md flex-col gap-3", disabled && "opacity-50", className)}
      onDragOver={onDragOver}
      onDragEnter={onDragEnter}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
    >
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        disabled={disabled}
        className="sr-only"
        tabIndex={-1}
        aria-hidden
        onChange={(e) => {
          if (e.target.files) ingest(e.target.files);
          e.target.value = ""; // allow re-picking the same file
        }}
      />

      <button
        ref={buttonRef}
        type="button"
        disabled={disabled}
        onClick={openPicker}
        aria-label={typeof label === "string" ? label : "Add files"}
        data-drag-active={dragActive || undefined}
        className={cn(
          "group relative isolate flex min-h-[168px] w-full flex-col items-center justify-center gap-3",
          "overflow-hidden rounded-xl border border-dashed px-6 py-8 text-center",
          "border-neutral-700 bg-neutral-900/40",
          "outline-none transition-[transform,border-color,background-color,box-shadow] ease-out",
          "focus-visible:border-transparent",
          !disabled && "cursor-pointer hover:border-neutral-500",
          still ? "duration-0" : "duration-300"
        )}
        style={{
          borderColor: dragActive ? accent : undefined,
          backgroundColor: dragActive ? `${accent}14` : undefined,
          transform: !still && dragActive ? "translateY(-3px) scale(1.005)" : undefined,
          boxShadow: dragActive ? `0 18px 40px -24px ${accent}, 0 0 0 1px ${accent}` : undefined,
          ["--tw-ring-color" as string]: accent,
        }}
      >
        {/* Faint coordinate grid — brightens while a drag hovers, static under
            reduced motion. Decorative. Edges fade via a radial mask. */}
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 -z-10",
            still ? "transition-none" : "transition-opacity duration-300 ease-out"
          )}
          style={{
            backgroundImage:
              "linear-gradient(to right, rgba(255,255,255,0.10) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.10) 1px, transparent 1px)",
            backgroundSize: "22px 22px",
            opacity: dragActive ? 0.85 : 0.4,
            maskImage: "radial-gradient(ellipse at center, black 45%, transparent 90%)",
            WebkitMaskImage: "radial-gradient(ellipse at center, black 45%, transparent 90%)",
          }}
        />
        {/* Focus ring drawn manually so it can adopt the accent hue. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-xl ring-2 ring-offset-0 opacity-0 transition-opacity group-focus-visible:opacity-100"
          style={{ boxShadow: `inset 0 0 0 2px ${accent}` }}
        />

        <svg
          aria-hidden
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
          strokeLinejoin="round"
          className={cn(
            "h-7 w-7 text-neutral-400 transition-transform ease-out",
            !still && "group-data-[drag-active]:-translate-y-0.5",
            still ? "duration-0" : "duration-300"
          )}
          style={{ color: dragActive ? accent : undefined }}
        >
          <path d="M12 15V3" />
          <path d="m7 8 5-5 5 5" />
          <path d="M5 15v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4" />
        </svg>

        <span className="flex flex-col gap-1">
          <span className="text-sm font-medium text-neutral-200">{label}</span>
          {defaultHint && (
            <span className="text-xs text-neutral-500">{defaultHint}</span>
          )}
        </span>
      </button>

      {/* Always mounted so AnimatePresence can play the exit of the LAST card
          too (a conditional list would unmount it before the exit runs). */}
      <ul
          className="flex flex-col gap-2 empty:hidden"
          aria-label={`Selected files, ${files.length}`}
        >
          <AnimatePresence initial={false}>
            {files.map(({ id, file }, index) => (
              <motion.li
                key={id}
                ref={(el) => {
                  if (el) cardRefs.current.set(id, el);
                  else cardRefs.current.delete(id);
                }}
                tabIndex={0}
                aria-label={`${file.name}, ${formatBytes(file.size)}. Press Delete to remove.`}
                onKeyDown={(e) => {
                  if (e.key === "Delete" || e.key === "Backspace") {
                    e.preventDefault();
                    remove(id);
                  }
                }}
                layout={still ? false : true}
                initial={still ? false : { opacity: 0, y: -8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={still ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
                transition={cardTransition}
                className={cn(
                  "group/card relative flex items-center gap-3 rounded-lg px-3 py-2.5",
                  "border border-neutral-800 bg-neutral-900",
                  // Soft downward inner-shadow — the card reads as seated.
                  "shadow-[inset_0_1px_0_rgba(255,255,255,0.04),inset_0_-6px_12px_-8px_rgba(0,0,0,0.9)]",
                  "outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
                )}
                style={{ ["--tw-ring-color" as string]: accent }}
              >
                {renderCard ? (
                  renderCard({ file, id, index, remove: () => remove(id) })
                ) : (
                  <>
                    <span
                      aria-hidden
                      className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-neutral-800 bg-neutral-950 text-[9px] font-semibold tracking-wide text-neutral-400"
                    >
                      {extensionOf(file.name)}
                    </span>
                    <span className="flex min-w-0 flex-1 flex-col">
                      <span className="truncate text-sm text-neutral-200">{file.name}</span>
                      <span className="truncate text-xs text-neutral-500">
                        {formatBytes(file.size)}
                        {file.type ? ` · ${file.type}` : ""}
                      </span>
                      {renderProgress?.({ file, id })}
                    </span>
                    <button
                      type="button"
                      // -1: the whole card is the focus target; the card's Delete
                      // key removes it, so this button stays out of the tab order.
                      tabIndex={-1}
                      aria-label={`Remove ${file.name}`}
                      onClick={(e) => {
                        e.stopPropagation();
                        remove(id);
                      }}
                      className={cn(
                        "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-neutral-500",
                        "transition-colors hover:bg-neutral-800 hover:text-neutral-200"
                      )}
                    >
                      <svg
                        aria-hidden
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="h-4 w-4"
                      >
                        <path d="M18 6 6 18" />
                        <path d="m6 6 12 12" />
                      </svg>
                    </button>
                  </>
                )}
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>
    </div>
  );
}
```
