UI Components Medium
Drop Zone Placeholder
A file drop zone exposes a clear placeholder while a native drag is over the target.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
--accent-soft: rgba(120, 169, 255, 0.14);
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
-webkit-font-smoothing: antialiased;
}
.demo {
width: min(680px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.demo__head h1 { margin: 0 0 .4rem; font-size: 1.35rem; letter-spacing: -.01em; }
.muted { color: var(--muted); margin: 0 0 1.5rem; font-size: .9rem; }
.hint { font-size: .85rem; color: var(--muted); }
.dz { margin: 0; }
.dz__input {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
.dz__zone {
position: relative;
display: grid;
place-items: center;
min-height: 240px;
padding: 2rem;
cursor: pointer;
border-radius: 16px;
border: 2px dashed var(--line);
background: var(--panel-2);
transition: border-color .18s ease, background .18s ease, transform .18s ease, box-shadow .18s ease;
}
.dz__zone:focus-visible,
.dz__zone[data-state="focus"] {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.dz__zone[data-state="over"] {
border-color: var(--accent);
border-style: solid;
background: linear-gradient(180deg, var(--accent-soft), transparent), var(--panel-2);
transform: scale(1.01);
}
.dz__resting,
.dz__placeholder {
grid-area: 1 / 1;
display: grid;
justify-items: center;
gap: .5rem;
text-align: center;
transition: opacity .16s ease, transform .16s ease;
}
.dz__placeholder {
opacity: 0;
transform: scale(.94);
pointer-events: none;
}
.dz__zone[data-state="over"] .dz__resting { opacity: 0; transform: scale(.96); }
.dz__zone[data-state="over"] .dz__placeholder { opacity: 1; transform: scale(1); }
.dz__icon {
width: 36px; height: 36px;
fill: none;
stroke: var(--accent);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.dz__resting h2 { margin: .25rem 0 0; font-size: 1.05rem; }
.dz__resting p { margin: 0; }
.dz__browse {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.dz__pulse {
width: 44px; height: 44px;
border-radius: 50%;
background: var(--accent-soft);
border: 2px solid var(--accent);
animation: dz-pulse 1.2s ease-out infinite;
}
@keyframes dz-pulse {
0% { box-shadow: 0 0 0 0 rgba(120, 169, 255, .35); }
100% { box-shadow: 0 0 0 20px rgba(120, 169, 255, 0); }
}
.dz__ph-title { font-size: 1.05rem; }
.dz__ph-count { color: var(--muted); font-size: .85rem; }
.dz__live { margin: .75rem .15rem 0; }
.files {
list-style: none;
margin: 1.25rem 0 0;
padding: 0;
display: grid;
gap: .5rem;
}
.files li {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem .9rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-2);
font-size: .85rem;
animation: dz-in .22s ease both;
}
.files__name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.files__size { color: var(--muted); font-variant-numeric: tabular-nums; }
.files__remove {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
font-size: 1.05rem;
line-height: 1;
padding: .25rem .4rem;
border-radius: 6px;
}
.files__remove:hover { color: var(--text); background: rgba(255, 255, 255, .07); }
@keyframes dz-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}(function () {
const zone = document.getElementById("dz-zone");
const input = document.getElementById("dz-input");
const list = document.getElementById("dz-files");
const status = document.getElementById("dz-status");
const count = document.getElementById("dz-count");
if (!zone || !input) return;
// Native DnD fires dragenter/dragleave for every descendant, so a single
// dragleave is not a reliable "left the zone" signal. Track depth instead.
let depth = 0;
let files = [];
const setState = (next) => zone.setAttribute("data-state", next);
function formatSize(bytes) {
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return (i === 0 ? n : n.toFixed(1)) + " " + units[i];
}
function describe(dt) {
if (!dt) return "Drop to add files";
let n = dt.items ? dt.items.length : 0;
if (!n && dt.files) n = dt.files.length;
if (!n) return "Drop to add files";
return n + (n === 1 ? " item" : " items") + " ready";
}
function render() {
list.textContent = "";
files.forEach((file, index) => {
const li = document.createElement("li");
const name = document.createElement("span");
name.className = "files__name";
name.textContent = file.name;
const size = document.createElement("span");
size.className = "files__size";
size.textContent = formatSize(file.size);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "files__remove";
remove.textContent = "×";
remove.setAttribute("aria-label", "Remove " + file.name);
remove.addEventListener("click", () => {
files.splice(index, 1);
render();
});
li.append(name, size, remove);
list.appendChild(li);
});
status.textContent = files.length
? files.length + (files.length === 1 ? " file" : " files") + " selected."
: "No files selected.";
}
function addFiles(fileList) {
for (const file of fileList) {
const dup = files.some((f) => f.name === file.name && f.size === file.size);
if (!dup) files.push(file);
}
render();
// Replace this with your upload / persistence callback.
}
// Stop the browser from navigating to a file dropped anywhere on the page.
["dragenter", "dragover", "dragleave", "drop"].forEach((type) => {
window.addEventListener(type, (e) => e.preventDefault());
});
zone.addEventListener("dragenter", (e) => {
e.preventDefault();
depth++;
count.textContent = describe(e.dataTransfer);
setState("over");
});
zone.addEventListener("dragover", (e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
});
zone.addEventListener("dragleave", (e) => {
e.preventDefault();
depth = Math.max(0, depth - 1);
if (depth === 0) setState("idle");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
depth = 0;
setState("idle");
if (e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
// Pointer / keyboard fallback: the zone proxies the visually hidden input.
zone.addEventListener("click", () => input.click());
zone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
zone.addEventListener("focus", () => {
if (zone.dataset.state === "idle") setState("focus");
});
zone.addEventListener("blur", () => {
if (zone.dataset.state === "focus") setState("idle");
});
input.addEventListener("change", () => {
addFiles(input.files);
input.value = "";
});
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drop Zone Placeholder</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Drop Zone Placeholder">
<header class="demo__head">
<h1>Drop Zone Placeholder</h1>
<p class="muted">Drag files from your desktop onto the target — a placeholder takes over the zone while the drag hovers. Click or press Enter for the keyboard fallback.</p>
</header>
<form class="dz" id="dz">
<input class="dz__input" type="file" id="dz-input" multiple aria-describedby="dz-hint" />
<div class="dz__zone" id="dz-zone" data-state="idle" role="button" tabindex="0"
aria-labelledby="dz-title" aria-describedby="dz-hint">
<div class="dz__resting">
<svg class="dz__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
</svg>
<h2 id="dz-title">Drop files here</h2>
<p id="dz-hint" class="hint">or <span class="dz__browse">browse your device</span></p>
</div>
<div class="dz__placeholder" aria-hidden="true">
<div class="dz__pulse"></div>
<strong class="dz__ph-title">Release to upload</strong>
<span class="dz__ph-count" id="dz-count"></span>
</div>
</div>
<p class="hint dz__live" role="status" aria-live="polite" id="dz-status">No files selected.</p>
</form>
<ul class="files" id="dz-files" aria-label="Selected files"></ul>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function DragDropPlaceholderZone() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Drop Zone Placeholder</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Drop Zone Placeholder
A file drop zone exposes a clear placeholder while a native drag is over the target.
Support notes
Native HTML5 DnD covers desktop semantics while Pointer Events handle touch-friendly reorder surfaces. Add a persistence callback where the demo updates the DOM.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.