Hopper
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.
motionfree
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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>
{files.length > 0 && (
<ul
className="flex flex-col gap-2"
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/hopperManual — install dependencies, then copy the source
npm install motionProps
| 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 |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.