File Drop Zone Active
The dashed outline energizes and the target lifts off the page while a file is held over it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useRef, useState, type DragEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · File Drop Zone Active
*
* While something is held over it, the target lifts off the page, an
* accent wash comes up inside it, and its dashed outline shifts by one
* period — a single deliberate movement rather than a loop, so the
* energy belongs to the drag and stops when the drag does.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so the zone reads correctly on a
* light page and on a dark one.
* Works with zero props; tune via `variant`, `title`, `width`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FileDropZoneActiveProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline inside the zone. */
title?: string;
/** Second line inside the zone. */
hint?: string;
/** Zone width in pixels — the dashed outline is drawn to it. */
width?: number;
/** Zone height in pixels. */
height?: number;
/** Active border, wash and icon color. */
accent?: string;
/** Fires with the names of everything dropped or chosen. */
onFiles?: (names: string[]) => void;
};
type VariantConfig = {
/** Lifts the zone toward the pointer while a file is held over it. */
lift: { type: "spring"; stiffness: number; damping: number };
/** How far it lifts, in pixels. */
rise: number;
/** Seconds for the dashed outline to shift one period. */
march: number;
/** Seconds for the accent wash to come up. */
wash: number;
};
// Quality rule: the lift spring stays at or above a 0.8 damping ratio.
// A target that bobs under a held file is a target the user has to aim
// at twice. The zone carries text, so it translates and never scales,
// and the dash shift is one-shot: a marching loop would keep drawing
// attention after the decision has already been made.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Colour and a hairline of movement. For a zone inside a busy form.
subtle: {
lift: { type: "spring", stiffness: 620, damping: 48 },
rise: 1.5,
march: 0.35,
wash: 0.12,
},
// The target clearly comes up to meet the file. All-purpose.
default: {
lift: { type: "spring", stiffness: 500, damping: 40 },
rise: 3,
march: 0.5,
wash: 0.16,
},
// A full lift with a longer shift, for an empty state whose whole job
// is to be dropped on.
playful: {
lift: { type: "spring", stiffness: 400, damping: 33 },
rise: 5,
march: 0.7,
wash: 0.2,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, outline or row fill
* that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Off-screen but in the accessibility tree — the announcement channel
* for a change the eye can see and a screen reader otherwise cannot. */
const SR_ONLY = {
position: "absolute" as const,
width: 1,
height: 1,
margin: -1,
padding: 0,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap" as const,
};
const DASH = 7;
const GAP = 6;
type Dropped = { name: string; detail: string };
export default function FileDropZoneActive({
variant = "default",
title = "Drop files to upload",
hint = "PDF, PNG or CSV, up to 25 MB each",
width = 300,
height = 138,
accent = "#5B5BD6",
onFiles,
}: FileDropZoneActiveProps) {
const [active, setActive] = useState(false);
const [items, setItems] = useState<Dropped[]>([]);
const [announcement, setAnnouncement] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Dragging across a child element fires dragleave on the parent, so
// the state has to count enters and leaves rather than trust either.
const depth = useRef(0);
const baseId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const accept = (dropped: Dropped[]) => {
if (dropped.length === 0) return;
setItems((current) => [...current, ...dropped]);
onFiles?.(dropped.map((item) => item.name));
setAnnouncement(
`${dropped.length} file${dropped.length === 1 ? "" : "s"} added: ${dropped
.map((item) => item.name)
.join(", ")}`
);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
depth.current = 0;
setActive(false);
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
accept(
files.map((file) => ({
name: file.name,
detail: `${Math.max(1, Math.round(file.size / 1024))} KB`,
}))
);
return;
}
// Nothing from the file system: this was a drag from elsewhere in
// the page, which is how a workspace usually moves its own documents
// around. The plain-text payload is the name.
const internal = event.dataTransfer.getData("text/plain").trim();
if (internal) accept([{ name: internal, detail: "From this workspace" }]);
};
return (
<div style={{ width, color: "inherit" }}>
<motion.div
role="group"
aria-labelledby={`${baseId}-title`}
onDragEnter={(event) => {
event.preventDefault();
depth.current += 1;
setActive(true);
}}
onDragOver={(event) => {
// Without this the browser navigates to the dropped file
// instead of handing it over.
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={() => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setActive(false);
}}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
animate={{ y: active && !reduceMotion ? -cfg.rise : 0 }}
transition={cfg.lift}
style={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 3,
width,
height,
borderRadius: 14,
textAlign: "center",
cursor: "pointer",
overflow: "hidden",
// The shadow is the other half of the lift: without it the zone
// slides rather than rises.
boxShadow: active ? "0 10px 26px rgba(0,0,0,0.18)" : "0 0 0 rgba(0,0,0,0)",
transition: "box-shadow 200ms ease-out",
}}
>
{/* The outline is drawn rather than bordered, so the dash pattern
is exact and its offset is animatable. */}
<svg
aria-hidden
width={width}
height={height}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<motion.rect
x={1}
y={1}
width={width - 2}
height={height - 2}
rx={13}
fill="none"
strokeWidth={2}
strokeDasharray={`${DASH} ${GAP}`}
initial={false}
animate={{
strokeDashoffset: active && !reduceMotion ? -(DASH + GAP) * 2 : 0,
}}
transition={{ duration: reduceMotion ? 0 : cfg.march, ease: "easeOut" }}
style={{
// The color settles on a CSS transition rather than through
// the animation loop: `color-mix()` is a value the loop
// cannot interpolate, and a border color is not motion.
stroke: active ? accent : tone(22),
transition: `stroke ${cfg.wash}s ease-out`,
}}
/>
</svg>
{/* The wash is a separate layer that fades, keeping the running
animation to opacity and transform. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: active ? 1 : 0 }}
transition={{ duration: cfg.wash, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 13,
background: `${accent}14`,
}}
/>
<motion.svg
viewBox="0 0 24 24"
width={26}
height={26}
fill="none"
aria-hidden
initial={false}
// The arrow steps up out of the tray: the zone's own answer to
// "let go here".
animate={{ y: active && !reduceMotion ? -2 : 0 }}
transition={cfg.lift}
style={{
position: "relative",
marginBottom: 5,
// Tint and dim settle on CSS, keeping the animation loop to
// transform and opacity.
color: active ? accent : "inherit",
opacity: active ? 1 : 0.5,
transition: "color 160ms ease-out, opacity 160ms ease-out",
}}
>
<path
d="M12 15.5 V4.5 M7.5 9 L12 4.5 L16.5 9"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 16 V18 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 V16"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
/>
</motion.svg>
<span
id={`${baseId}-title`}
style={{ position: "relative", fontSize: 13.5, fontWeight: 650 }}
>
{active ? "Let go to add" : title}
</span>
<span style={{ position: "relative", fontSize: 11.5, opacity: 0.5 }}>
{hint}
</span>
<button
type="button"
// Drag and drop is a pointer gesture, so the keyboard path is a
// real button rather than a focusable rectangle that does
// nothing when Enter is pressed.
onClick={(event) => {
event.stopPropagation();
inputRef.current?.click();
}}
style={{
position: "relative",
marginTop: 9,
padding: "5px 11px",
borderRadius: 8,
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(8),
border: `1px solid ${tone(14)}`,
cursor: "pointer",
}}
>
Browse files
</button>
<input
ref={inputRef}
type="file"
multiple
tabIndex={-1}
aria-hidden
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
accept(
files.map((file) => ({
name: file.name,
detail: `${Math.max(1, Math.round(file.size / 1024))} KB`,
}))
);
event.target.value = "";
}}
style={SR_ONLY}
/>
</motion.div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
<AnimatePresence initial={false}>
{items.map((item) => (
<motion.div
key={`${item.name}-${item.detail}`}
layout="position"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{ duration: reduceMotion ? 0.1 : 0.2, ease: "easeOut" }}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "8px 10px",
borderRadius: 9,
background: tone(6),
border: `1px solid ${tone(10)}`,
fontSize: 12.5,
}}
>
<svg viewBox="0 0 16 16" width={14} height={14} fill="none" aria-hidden>
<path
d="M9 1.8 H4.6 a1.6 1.6 0 0 0 -1.6 1.6 v9.2 a1.6 1.6 0 0 0 1.6 1.6 h6.8 a1.6 1.6 0 0 0 1.6 -1.6 V5.8 Z M9 1.8 V5.8 h4"
stroke="currentColor"
strokeWidth={1.3}
strokeLinejoin="round"
style={{ opacity: 0.65 }}
/>
</svg>
<span style={{ flex: 1, minWidth: 0, fontWeight: 600 }}>{item.name}</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{item.detail}</span>
</motion.div>
))}
</AnimatePresence>
</div>
<div role="status" aria-live="polite" style={SR_ONLY}>
{announcement}
</div>
</div>
);
}About this pattern
The whole job of this state is to say "yes, here" before the pointer is released. The target rises a few pixels with a shadow underneath it, an accent wash comes up inside, and the dashed outline shifts by exactly one dash period — one deliberate movement rather than a marching loop, because a loop keeps demanding attention after the decision has already been made. The zone translates and never scales, since the invitation is written in text. Enters and leaves are counted rather than trusted, so dragging across the icon inside does not make the target flicker, and the keyboard path is a real browse button rather than a rectangle that ignores Enter.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- File browser
A target that answers before the pointer is released.
Related patterns
- OTP Paste FillA pasted block of digits lands in the boxes as a quick left-to-right cascade instead of appearing all at once.
- Quantity AdjustThe count rolls in the direction it moved and the order total takes a brief tint.
- Slider Drag ValueThe handle stays under the finger while held, with a value bubble that rises on grab.