Dropzone
A file drop target with a programmatic ingress a native host can drive, validation, a rejection surface, paste-to-upload, and a per-file upload list.
Live demo
Dropzone takes files — by drag-and-drop, by keyboard through a real <input type="file">, by paste, or programmatically. That last one is the point.
Inside a native shell (Tauri, Electron, WebView2) the operating system hands a file drop to the webview, not to the document, so DOM dragover / drop never fire and a DOM-only drop target is silently inert in exactly the desktop apps this library exists to serve. So the DOM listener here is one input, not the input: addFiles(items) is the public seam every ingress runs through, and it takes File objects, bare filesystem paths (what a native drop actually gives you — there is no File behind them), or descriptors carrying whatever the host knows. setDragging(active, rejecting) drives the hover and reject skins from a host that owns the drag, and routeNativeDrop(state, { x, y, items }) wires a shell's own drag events straight through to whichever zone sits under the pointer. Everything is validated identically no matter where it came from: accept by extension or mime (a path-only file has its type inferred from its extension), max-size per file (5mb, 500kb, or bytes), max-files by count, plus duplicate rejection — and every failure lands on a real error surface with a human message, not a silent drop. Every batch reports itself: file-drop carries what was accepted, what was refused, and which ingress it came through; file-reject carries the refusals alone; file-remove fires when a row is dropped; and change fires whenever the accepted list moves. Accepted files render as an upload list with a per-file bar the host drives (setProgress, setStatus) and a remove button; removeFile(id) moves focus to the next row. Keyboard and screen-reader access is not an afterthought: a real, focusable <input type="file"> sits under the surface as the keyboard path and as the <form> value, and the accepted list is written back to it so a rejected file can never post. It server-renders whole, too — the @xtyle/astro binding emits the resolved surface, the derived hint, and that file input, so the zone is a working picker and form field before any JavaScript loads (and stays one, with static, if none ever does). The surface, its idle / hover / reject / disabled states, the rejection list, and the upload rows are all the fill's markup (component.dropzone) — a mod reshapes every one of them while the element keeps the drop logic.
When to use
How this component composes with the rest of the set.
Props
15 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
States
dragging
A drag is over the zone and would be accepted: the surface goes solid and accent-colored. Reachable from a DOM drag or from setDragging(true).
rejecting
The drag carries only files the zone would refuse, so it says so before the drop: a danger-colored surface and dropEffect: none. Only previewed when every accept pattern is a mime pattern — mid-drag a browser exposes types but not names, so an extension rule stays honest by staying neutral.
disabled
Every ingress refused; the surface goes muted and un-clickable and the file input is disabled.
focus-visible
Keyboard focus on the hidden file input draws the ring on the visible surface, so the tab stop is never invisible.
file-error
A row whose upload failed (setStatus(id, "error", message)): danger border, danger bar, and the message under the name.
file-done
A finished upload: the bar goes success-colored at 100%.
Anatomy
The named parts that make up the component, with their selectors.
root
The host element: the drop target, the hit-test box a native host resolves a pointer against, and the class hook for the dragging / disabled / invalid states.
surface
The drop surface the fill draws, as a <label> pointing at the hidden file input — so a click or a tap opens the picker with no JavaScript at all. Carries the idle, dragging, rejecting, and disabled skins.
icon
The upload glyph the fill draws inside the surface. A real node in the fill, so a mod can swap or drop it.
prompt
The headline inside the surface (prompt, default Drop files here). Also the default slot: give the element children and they become the prompt instead.
hint
The constraint line under the prompt. Written from hint, or derived from accept / max-size / max-files when unset. The file input's aria-describedby points at it.
browse
The faux browse chip. Presentational (aria-hidden) — the surface <label> is what actually opens the picker, so this is never a second tab stop.
errors
The rejection surface: a role="alert" list, one human message per refused file (wrong type, over the size limit, over the count, already added).
list
The upload list, an aria-live="polite" region so an added or removed file is announced.
file
One upload row: glyph, name, size and status, remove button, and the progress bar. Rows are patched in place on a progress tick, so focus survives an upload.
track
One file's progress groove, a role="progressbar" the host drives through setProgress(). Its fill turns success-colored when done and danger-colored on a failed upload.
remove
A row's remove button. Removing moves focus to the next row's button (or back to the field), so a keyboard user is never dropped to the top of the page.
input
The real <input type="file"> — plumbing, not chrome: visually hidden but focusable, it is the keyboard and screen-reader path and the value a <form> submits. It stays in the element (never the fill) because a mod cannot see it, and its .files is rebuilt from the accepted list on every change, so a rejected file cannot post. The Astro binding server-renders it (a zero-JS zone still has to pick and post files), and the element adopts that input — and its id, which the rendered surface already points at — instead of minting a second one on upgrade.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent
--accent-bg
--accent-text
--bg-0
--bg-1
--border-thick
--border-thin
--danger
--danger-bg
--danger-text
--duration-fast
--ease-standard
--fg-0
--fg-1
--fg-2
--fg-3
--fg-disabled
--font-mono
--font-sans
--line
--line-2
--neutral-bg
--radius-full
--radius-md
--radius-sm
--ring
--space-1
--space-2
--space-3
--space-4
--space-7
--state-disabled
--state-hover
--success
--text-2xl
--text-body
--text-sm
--text-xs
--weight-medium
Slots
Custom prompt content inside the surface, replacing the prompt text. Use it for a richer pitch (an icon row, a link, a policy note).
Accessibility
Code
Native drops, validation, and a driven upload list
A validated, multi-file zone that accepts a DOM drop, a paste, a keyboard pick — and a programmatic drop from a native host, which is the only path that works inside a Tauri webview. Rejections surface with reasons; accepted files get a bar the host drives.
<xtyle-dropzone
label="Upload attachments"
accept="image/*,.pdf"
max-size="5mb"
max-files="5"
multiple
paste
name="attachments"></xtyle-dropzone>
<script type="module">
import "@xtyle/core/elements";
import { routeNativeDrop } from "@xtyle/core/elements/dropzone.js";
const zone = document.querySelector("xtyle-dropzone");
// The DOM drop is only ONE input. In a native shell (Tauri, Electron) the OS hands the drop to the
// webview, so `dragover` / `drop` never fire — drive the same zone programmatically instead.
// `addFiles` takes File objects, filesystem paths, or descriptors, and validates all of them the same way.
zone.addFiles(["C:/shots/hero.png", "C:/docs/brief.pdf"]);
// …or let a shell's own drag events route themselves to whichever zone is under the pointer:
// import { getCurrentWebview } from "@tauri-apps/api/webview";
// getCurrentWebview().onDragDropEvent(({ payload }) => {
// const at = payload.position && {
// x: payload.position.x / devicePixelRatio,
// y: payload.position.y / devicePixelRatio,
// };
// if (payload.type === "over") routeNativeDrop("over", { ...at });
// else if (payload.type === "drop") routeNativeDrop("drop", { ...at, items: payload.paths });
// else routeNativeDrop("leave", {});
// });
zone.addEventListener("file-drop", async (event) => {
for (const file of event.detail.accepted) {
zone.setProgress(file.id, 0);
await upload(file, (percent) => zone.setProgress(file.id, percent));
zone.setStatus(file.id, "done");
}
});
zone.addEventListener("file-reject", (event) => {
console.warn(event.detail.rejected.map((r) => r.message));
});
zone.addEventListener("file-remove", (event) => {
cancelUpload(event.detail.file.id);
});
</script>
<script lang="ts">
import { Dropzone } from "@xtyle/svelte";
import type { XtyleDropzone } from "@xtyle/core/elements";
let zone = $state<XtyleDropzone>();
// The programmatic path is the same one a native shell drives: paths in, validated files out.
function onNativeDrop(paths: string[]) {
zone?.addFiles(paths);
}
async function onDrop(event: CustomEvent) {
for (const file of event.detail.accepted) {
await upload(file, (percent: number) => zone?.setProgress(file.id, percent));
zone?.setStatus(file.id, "done");
}
}
</script>
<Dropzone
bind:element={zone}
label="Upload attachments"
accept="image/*,.pdf"
maxSize="5mb"
maxFiles={5}
multiple
paste
name="attachments"
onfiledrop={onDrop}
onfilereject={(event) => console.warn(event.detail.rejected)} />
---
import Dropzone from "@xtyle/astro/Dropzone.astro";
---
<!-- Server-rendered: the surface, the hint, the browse chip, and the real file input all ship in the
HTML, so the zone is a working picker and form field before a byte of JavaScript arrives. -->
<Dropzone
label="Upload attachments"
accept="image/*,.pdf"
maxSize="5mb"
maxFiles={5}
multiple
paste
name="attachments"
prompt="Drop your files here"
hint="Images and PDFs, up to 5 MB each" />
<!-- `static` keeps the server render and never loads the runtime: a plain file field in a plain form,
zero JS. Drag-and-drop, paste, and the upload list are what hydration buys you. -->
<Dropzone static name="resume" label="Attach a résumé" accept=".pdf,.docx" />
<script>
import "@xtyle/core/elements";
import { routeNativeDrop } from "@xtyle/core/elements/dropzone.js";
const zone = document.querySelector("xtyle-dropzone");
// A native (OS) drop never reaches the DOM inside a shell webview; route it in by hand.
zone?.addFiles(["/tmp/report.pdf"]);
zone?.addEventListener("file-drop", (event) => console.log(event.detail.accepted));
</script>