# File drop

> A drop area around a native file input: the drop and the picker are the input’s; the area marks a drag, checks type, size and count, and lists the files with thumbnails and remove buttons.

Page: https://svelte-lean.vercel.app/primitives/file-drop · Markdown: https://svelte-lean.vercel.app/primitives/file-drop.md · Index: https://svelte-lean.vercel.app/llms.txt

A drop area for files, made of a native file input stretched over it: drop, choose or paste files, check their type, size and count, and list them with thumbnails and remove buttons.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="file-drop".
	import '@svelte-lean/styles/file-drop.css';
</script>

<div data-slean="file-drop" data-slean-preview>
	<div data-slean-part="area">
		<input
			id="file-drop-upload"
			type="file"
			name="files"
			multiple
			accept="image/*,.pdf"
			aria-labelledby="file-drop-hint"
			aria-describedby="file-drop-rules file-drop-files"
			data-slean-part="input"
		/>
		<span data-slean-part="icon" aria-hidden="true"></span>
		<p id="file-drop-hint">Drop images or PDFs here, or choose files</p>
		<p id="file-drop-rules">Each choice is added to the list</p>
	</div>
	<output id="file-drop-files" for="file-drop-upload" data-slean-part="output"></output>
	<ul role="list" aria-label="Chosen files" data-slean-part="list"></ul>
</div>
```

Drag files onto the area, or focus it and press Enter to open the picker; with focus on it, Ctrl+V pastes an image. Each choice is added to the list; a remove button takes a file out of the input.

## Examples

### Drop area alone

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
</script>

<!-- No list: the root is the drop area, and the output names the chosen files. -->
<div data-slean="file-drop">
	<input
		id="file-drop-plain"
		type="file"
		name="files"
		multiple
		aria-labelledby="file-drop-plain-hint"
		aria-describedby="file-drop-plain-files"
		data-slean-part="input"
	/>
	<span data-slean-part="icon" aria-hidden="true"></span>
	<p id="file-drop-plain-hint">Drop files here, or choose them</p>
	<output id="file-drop-plain-files" for="file-drop-plain" data-slean-part="output"></output>
</div>
```

Without a list the root is the drop area, a new choice replaces the files as the platform does, and the output names them.

### Type, size and count

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
	import type { FileDropRejectDetail } from '@svelte-lean/primitives/file-drop';
	import { on } from 'svelte/events';

	let refused = $state('');

	// The behavior shows each refusal in the list and the output; the event carries the reasons.
	function onReject(event: Event) {
		const { rejected } = (event as CustomEvent<FileDropRejectDetail>).detail;
		refused = rejected.map(({ file, reason }) => `${file.name} (${reason})`).join(', ');
	}
</script>

<div
	data-slean="file-drop"
	data-slean-maxsize={1_000_000}
	data-slean-maxcount="3"
	data-slean-sizeerror={'{label} is over 1 MB'}
	{@attach (node) => on(node, 'slean:reject', onReject)}
>
	<div data-slean-part="area">
		<input
			id="file-drop-checked"
			type="file"
			name="images"
			multiple
			accept="image/png,image/jpeg"
			aria-labelledby="file-drop-checked-hint"
			aria-describedby="file-drop-checked-rules file-drop-checked-files"
			data-slean-part="input"
		/>
		<span data-slean-part="icon" aria-hidden="true"></span>
		<p id="file-drop-checked-hint">Drop PNG or JPEG images here, or choose them</p>
		<p id="file-drop-checked-rules">Up to 3 images, 1 MB each</p>
	</div>
	<output id="file-drop-checked-files" for="file-drop-checked" data-slean-part="output"></output>
	<ul role="list" aria-label="Chosen images" data-slean-part="list"></ul>
</div>
<p>Last refused: {refused || 'none'}</p>
```

accept, data-slean-maxsize (bytes) and data-slean-maxcount. A refused file leaves the input, is listed with its reason and announced by the output; slean:reject carries the reasons to the application.

### Picture cards

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
</script>

<!-- The area is the last tile of the list; it goes while the four tiles are taken. -->
<div
	data-slean="file-drop"
	data-variant="picture-card"
	data-slean-preview
	data-slean-maxcount="4"
>
	<ul role="list" aria-label="Product photos" data-slean-part="list">
		<li data-slean-part="area">
			<input
				id="file-drop-product"
				type="file"
				name="product"
				multiple
				accept="image/*"
				aria-labelledby="file-drop-product-add"
				data-slean-part="input"
			/>
			<span id="file-drop-product-add">Add photo</span>
		</li>
	</ul>
</div>
```

data-variant="picture-card": the area is the last tile of the list, the whole tile takes a click or a drop, and it goes while the four tiles are taken.

### One photo, round

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
</script>

<!-- One file: the tile is replaced by the photo; remove it to choose another. -->
<div
	data-slean="file-drop"
	data-variant="picture-circle"
	data-slean-preview
	data-slean-maxsize={2_000_000}
>
	<ul role="list" aria-label="Avatar" data-slean-part="list">
		<li data-slean-part="area">
			<input
				id="file-drop-avatar"
				type="file"
				name="avatar"
				accept="image/*"
				aria-labelledby="file-drop-avatar-add"
				data-slean-part="input"
			/>
			<span id="file-drop-avatar-add">Avatar</span>
		</li>
	</ul>
</div>
```

data-variant="picture-circle" on a single input: the photo takes the tile's place; remove it to choose another.

### Upload progress

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
	import { fileDropItem } from '@svelte-lean/primitives/file-drop';

	let root: HTMLElement;

	// The transport is the application's. Here a timer stands in for it; a page sends each file
	// with fetch() or XMLHttpRequest and moves the bar from its progress events.
	function onchange(event: Event) {
		for (const file of (event.currentTarget as HTMLInputElement).files ?? []) {
			const item = fileDropItem(root, file);
			if (!item || item.hasAttribute('aria-busy')) continue;
			const bar = document.createElement('progress');
			bar.max = 100;
			bar.value = 0;
			bar.setAttribute('aria-label', `Uploading ${file.name}`);
			item.setAttribute('aria-busy', 'true');
			item.append(bar);
			const timer = setInterval(() => {
				bar.value += 20;
				if (bar.value < 100) return;
				clearInterval(timer);
				bar.remove();
				item.setAttribute('aria-busy', 'false');
			}, 300);
		}
	}
</script>

<div bind:this={root} data-slean="file-drop" data-slean-preview>
	<div data-slean-part="area">
		<input
			id="file-drop-sent"
			type="file"
			name="attachments"
			multiple
			{onchange}
			aria-labelledby="file-drop-sent-hint"
			data-slean-part="input"
		/>
		<span data-slean-part="icon" aria-hidden="true"></span>
		<p id="file-drop-sent-hint">Drop attachments here, or choose them</p>
	</div>
	<ul role="list" aria-label="Attachments" data-slean-part="list"></ul>
</div>
```

fileDropItem() finds a file's item; the application marks it aria-busy and appends a progress bar. The transport here is a timer.

### In a form

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/file-drop.css';

	let files = $state<FileList>();
	let status = $state('');

	function onsubmit(event: SubmitEvent) {
		// A real form posts the files; this one only reports them.
		event.preventDefault();
		status = `${files?.[0]?.name} is ready to upload.`;
	}
</script>

<form method="post" enctype="multipart/form-data" class="upload" {onsubmit}>
	<div data-slean="file-drop">
		<input
			id="file-drop-report"
			type="file"
			name="report"
			accept=".pdf,.csv"
			required
			bind:files
			aria-labelledby="file-drop-report-hint"
			aria-describedby="file-drop-report-file"
			data-slean-part="input"
		/>
		<span data-slean-part="icon" aria-hidden="true"></span>
		<p id="file-drop-report-hint">Drop the monthly report here, or choose it</p>
		<p>PDF or CSV, one file</p>
		<output id="file-drop-report-file" for="file-drop-report" data-slean-part="output"></output>
	</div>
	<button data-slean="button" disabled={!files?.length}>Upload</button>
	<p role="status">{status}</p>
</form>

<style>
	.upload {
		display: grid;
		gap: 0.75rem;
		justify-items: start;
	}

	.upload > [data-slean='file-drop'] {
		justify-self: stretch;
	}
</style>
```

The file is the form value of the input; required also blocks a submit without one.

### Disabled

```svelte
<script lang="ts">
	import '@svelte-lean/styles/file-drop.css';
</script>

<!-- disabled on the input: no drop, no picker and no drag mark. -->
<div data-slean="file-drop">
	<input
		id="file-drop-locked"
		type="file"
		name="files"
		disabled
		aria-labelledby="file-drop-locked-hint"
		data-slean-part="input"
	/>
	<p id="file-drop-locked-hint">Uploads are closed for this project</p>
</div>
```

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="file-drop"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/file-drop/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/file-drop.css';
```

Put the input in an element marked `data-slean-part="area"`: that element is the drop area. Name the input with `aria-labelledby` pointing at the instruction, and describe it with the rules and the output. Without a list the root itself can be the area.

Add `<ul role="list" data-slean-part="list">` for a file list (`role="list"` keeps list semantics in Safari once the bullets are removed), and `data-slean-preview` on the root for thumbnails of image files. Read the files from the input as usual: `bind:files`, `FormData` or a `change` listener.

Checks: `accept` (checked for drops and pastes too, which the picker’s filter does not cover), `data-slean-maxsize` in bytes and `data-slean-maxcount` for a `multiple` input. Each refused file is listed with a message from `data-slean-typeerror`, `data-slean-sizeerror` or `data-slean-counterror` (`{label}` is the file’s name; English by default) and reported with `slean:reject`. Validate on the server as well.

Words: `data-slean-remove` names the remove buttons (`Remove {label}` by default). In a Svelte template write a literal `{label}` as a string expression: `data-slean-remove={'Kaldır {label}'}`.

From code: `setFileDropFiles(root, files)` replaces the files without checks and dispatches `input` and `change`; `fileDropItem(root, file)` returns the item of a file, the place for an upload’s `aria-busy` and `<progress>`; `fileSize(bytes, lang)` formats a size with the platform’s unit words.

`reasons.svelte`

```svelte
<script lang="ts">
	import type { FileDropRejectDetail } from '@svelte-lean/primitives/file-drop';
	import { on } from 'svelte/events';

	// reason is 'type', 'size' or 'count'. preventDefault() keeps the behavior from writing its
	// own messages; the refused files are out of the input either way.
	function onReject(event: Event) {
		const { rejected } = (event as CustomEvent<FileDropRejectDetail>).detail;
		for (const { file, reason } of rejected) console.log(file.name, reason);
	}
</script>

<div
	data-slean="file-drop"
	data-slean-maxsize={5_000_000}
	{@attach (node) => on(node, 'slean:reject', onReject)}
>
	…
</div>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="file-drop">` | – | yes | Options data-slean-maxsize, -maxcount, -preview, -remove, -typeerror, -sizeerror, -counterror. States data-slean-dragging, -rejected, -full. |
| area | `any element around the input` | `area` | no | The drop area when the root also holds a list; without it the root is the area. In picture cards, the last \<li\> of the list. |
| input | `<input type="file" aria-labelledby="…">` | `input` | yes | Stretched over the area by the stylesheet; named by the visible instruction. |
| icon | `<span aria-hidden="true">` | `icon` | no | Empty, in the area: the upload icon. In an item: the generated type mark. |
| output | `<output for="…">` | `output` | no | The names of the chosen files, then one line per refused file. |
| list | `<ul role="list">` | `list` | no | Receives the items, before its child that holds the input. |
| item | `<li> (generated)` | `item` | no | One per file; data-slean-rejected on a refused one. aria-busy is the application’s. |
| media | `<img alt=""> (generated)` | `media` | no | The thumbnail of an image file, with data-slean-preview. |
| title | `<span> (generated)` | `title` | no | The file’s name, or the refusal message. |
| description | `<span> (generated)` | `description` | no | The size, with the platform’s unit words. |
| remove | `<button type="button"> (generated)` | `remove` | no | Named by data-slean-remove (Remove {label} by default). |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | in the page | Reaches the input (the area shows the ring) |
| `Enter`/`Space` | focus on the input | Opens the file picker (native) |
| `Ctrl+V`/`Cmd+V` | focus on the input | Adds an image from the clipboard (Chromium, Firefox) |
| `Tab` | after the input | Reaches each remove button |
| `Enter`/`Space` | focus on a remove button | Removes the file; focus moves to the next remove button, else the input |

## Styling

`file-drop.css` draws the dashed area (the root, or the `area` part), stretches the transparent input over it while scripting is enabled, marks it during a drag (`data-slean-dragging`) and on `:user-invalid`, moves the focus ring to it, draws the upload icon of an empty `icon` part, and lays out the list: rows with a thumbnail or a type mark, the name, the size and the remove button, and refused rows in the error color. `data-variant="picture-card"` and `"picture-circle"` turn the list into square or round tiles with the area as the last one, hidden at the file limit (`data-slean-full`). `data-color` on the root or an ancestor colors the drag state (primary by default). The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | gap, column-gap |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color, border-color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding, inset-inline, inset-block-end |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | padding |
| `--slean-border-strong` | `var(--slean-neutral-8)` | border |
| `--slean-radius-lg` | `calc(var(--slean-radius) * 1.4)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-icon-upload` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 10.5V2.5m0 0L5 5.5m3-3l3 3M2.5 10.5v1.5a1.5 1.5 0 001.5 1.5h8a1.5 1.5 0 001.5-1.5v-1.5' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color, color |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-font-weight-semibold` | `600` | font-weight |
| `--slean-icon-file` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Cpath d='M9 1.75H4.5a1.5 1.5 0 00-1.5 1.5v9.5a1.5 1.5 0 001.5 1.5h7a1.5 1.5 0 001.5-1.5V5.75z'/%3E%3Cpath d='M9 1.75v4h4'/%3E%3C/g%3E%3C/svg%3E")` | mask |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-block-start, gap, row-gap, inset-block-start, inset-inline-end |
| `--slean-icon-close` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-muted-hover` | `var(--slean-neutral-4)` | background |
| `--slean-file-drop-tile-size` | `6.5rem` | inline-size, block-size |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-icon-plus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5v9M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-elevation-1` | `0 1px 2px oklch(0% 0 0 / 0.08)` | box-shadow |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:empty`, `:enabled`, `:focus-visible`, `:hover`, `:user-invalid`, `[aria-busy="true"]`.

Variant attributes: `data-slean-dragging`; `data-slean-rejected`; `data-variant` (`picture-card`, `picture-circle`); `data-slean-full`.

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2983 B brotli · 3324 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input type="file">`, `drop`, `paste`, `DataTransfer`, `URL.createObjectURL`, `Intl.ListFormat`, `@media (scripting)`
- **Shared listeners**: dragenter, dragleave, drop, paste, input, change, click, reset
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<input type="file"> stretched over a drop area; DataTransfer for the file list`

Tier 1. The file drop registers `dragenter`, `dragleave`, `drop`, `paste`, `input`, `change`, `click` and `reset` handlers with the shared router, one listener per type for the whole page; it never handles `dragover`, which fires continuously. A thousand areas keep one listener per type (`tests/file-drop.test.ts`). The only state outside attributes is the `File` behind each generated item, held in a `WeakMap` keyed by the item and released with it. Thumbnails exist only with `data-slean-preview`; each object URL is released as soon as its image is decoded.

Why this implementation

A file input already accepts a dropped file: it becomes the input’s value and the input fires `input` and `change`, exactly as when the file is chosen in the picker. Drop-zone components rebuild that with `dragover` handlers, `preventDefault()` and a hidden input they fill by hand.

Here the input is the drop zone. The stylesheet stretches it, transparent, over the area, so a drop or a click anywhere on it reaches the input, and `accept`, `multiple`, the form value, the keyboard and the accessible name stay the browser’s. What a transparent input cannot do is show itself or refuse a file: the behavior marks the area during a drag, checks each file, writes the chosen names into an `<output>` and, with a list, renders one item per file.

The list is the input’s value drawn, not a copy of it. A remove button builds the input’s new `FileList` with `DataTransfer` and dispatches `input` and `change`, so `bind:files`, `FormData` and a submit see the same files as the list. Each choice is added to the files listed, because a file input forgets its earlier files on every choice.

### The browser owns

- the drop itself: a file dropped on a file input becomes its value and fires input and change
- the picker, accept (in the picker), multiple, required, webkitdirectory and the form value
- the keyboard path (Enter or Space opens the picker) and the accessible name

### Svelte Lean owns

- a mark on the area while a file is dragged over it (data-slean-dragging)
- the checks: accept for drops and pastes, data-slean-maxsize and data-slean-maxcount; refused files leave the input, are listed with a reason and reported with slean:reject
- the file list: one item per file with a thumbnail (data-slean-preview) or type mark, name, size and a remove button that rewrites the input; choices add up
- an image pasted into the focused input
- the chosen files’ names in the output, as a list in the page’s language
- file-drop.css: the input stretched, transparent, over the area; the dashed area, the drag and focus states, rows and picture-card tiles; the native input without JavaScript

Accessibility

- The input is the control: a native file input, named by the visible instruction and reached with Tab; it is the area’s only tab stop.
- Enter or Space opens the picker, which is the keyboard path; dropping is a pointer gesture with no keyboard equivalent. Ctrl+V on the focused input pastes an image in Chromium and Firefox.
- The output names the chosen files and the refused ones with their reasons, and describes the input, so each choice is announced.
- Each remove button is a native button named after its file. Removing moves focus to the next remove button, else the previous one, else the input.
- Thumbnails have an empty `alt`: the name beside them names the file. The type mark is hidden from assistive technology.
- The focus ring is drawn around the area while the input has keyboard focus.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Dropping files on a file input` | Widely available | Not applicable; accept does not filter dropped files, the behavior checks them |
| `DataTransfer constructor, assigning input.files` | Widely available | Not applicable |
| `URL.createObjectURL, HTMLImageElement.decode()` | Widely available | Not applicable; a Content Security Policy needs img-src blob: |
| `paste at a focused file input` | Chromium, Firefox | Safari dispatches no paste there; nothing happens |
| `Intl.ListFormat, Intl.NumberFormat units` | Baseline 2021 | The names are joined with commas |
| `@media (scripting), :has()` | Baseline 2023 | The input stays stretched and invisible without JavaScript |

Without JavaScript

The input is shown as it is under `@media (scripting: none)`: the platform's button and file name, a native drop target and the form value all work. No list, checks or thumbnails.

Server rendering and hydration

Render the markup with an empty output and list. Nothing is written before a drag, a choice or a paste.

Before the behavior loads, a drop or a choice already sets the input's files; the list, the checks and the names start with the next choice.

Compatibility

Dropping on a file input, the drag events, `DataTransfer`, object URLs, `HTMLImageElement.decode()` and `Intl.ListFormat` are widely available; `@media (scripting)` and `:has()` are Baseline 2023. Safari dispatches no `paste` at a file input. A Content Security Policy needs `img-src blob:` for the thumbnails. Uploading is application code.

Tests and source

- `packages/primitives/tests/file-drop.test.ts` Vitest: the drag mark, checks and messages, items, adding up, removal and focus, previews, paste, reset, 1000 roots
- `apps/playground/tests/primitives/file-drop.spec.ts` Playwright: files dropped from outside the page, thumbnails, checks, remove by keyboard, picture cards, paste, reset, RTL, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/file-drop/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/file-drop/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/file-drop/behavior.ts` the behavior definition
- `packages/primitives/src/file-drop/register.ts` the registration module
- `packages/styles/css/file-drop.css` the optional stylesheet
