File Upload
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/file-uploadPlain-CSS theme — no build step
npx astrocraft-ui add forms/file-upload --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/file-upload --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/file-upload --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/file-upload --theme tailwind --bridge lumosLive demo
FileUpload
Drop files on the zone or use the button — either way they land in the real<input type="file">, so the form submits them normally. Drop something that is not an image and it is refused: accept filters the picker and nothing else, so the check has to happen here. The list is a live region, because a drop moves no focus and would otherwise be silent.
No files chosen
Some files were not of an accepted type.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `FileDropzone.astro` | `file-dropzone` | — | — | — |
| `FileItem.astro` | `file-item` `file-item-name` `file-item-remove` `file-item-size` | — | — | — |
| `FileItemProgress.astro` | `file-item-progress` | — | — | — |
| `FileList.astro` | `file-item-template` `file-list` `file-list-empty` `file-list-region` | — | — | — |
| `FileTrigger.astro` | `file-trigger` | `data-variant`: `primary` · `secondary` · `outline` · `ghost` `data-size`: `sm` · `md` · `lg` | — | — |
| `FileUpload.astro` | `file-upload` `file-upload-error` `file-upload-input` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-name` `data-dragging` | `[hidden]` |
Source
What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/file-upload/FileDropzone.astro — FileUpload compound part (see ../../README.md).
// The drop target. FileUpload's script does the one thing that makes a dropzone work at all:
// `preventDefault()` on `dragover`. Without it the browser's own default wins and dropping a file
// NAVIGATES AWAY to open it — losing the page, the form and everything typed into it.
//
// While a drag is over it the script sets `data-dragging="true"` (a data attribute, never a class),
// which is the hook a theme uses to show the target is live.
//
// A drop target cannot be operated without a pointer, so FileTrigger is the route for everyone
// else — put one inside this and say so in the text. It carries no role of its own precisely BECAUSE
// it usually wraps that button: `aria-hidden` on a container is inherited by everything in it, and
// would hide the one control that makes the component keyboard-accessible.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div">;
const { class: className, ...rest } = Astro.props;
---
<div data-slot="file-dropzone" class={className} {...rest}>
<slot />
</div>
---
// src/components/ui/forms/file-upload/FileItem.astro — FileUpload compound part (see ../../README.md).
// One row of the file list: name, size, whatever you slot in (a FileItemProgress, a thumbnail), and
// a remove button.
//
// FileList renders ONE of these inside a <template> and FileUpload's script clones it per file. That
// is how this library generates a list without generating markup: the shape of a row is written here,
// in Astro, where `pnpm slots` can see the hooks it emits and a theme can style them — rather than
// in a template literal inside a script, which is the usual way this component goes wrong.
//
// The remove button's accessible name is completed at runtime with the file's own name, because a
// list of five buttons all announcing "Remove" tells a screen reader user nothing about which one
// they are on.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"li"> & {
name?: string;
size?: string;
/** The first half of the remove button's name; the file's name is appended at runtime. */
removeLabel?: string;
};
const { name = "", size = "", removeLabel = "Remove", class: className, ...rest } = Astro.props;
---
<li data-slot="file-item" data-name={name} class={className} {...rest}>
<span data-slot="file-item-name">{name}</span>
<span data-slot="file-item-size">{size}</span>
<slot />
<button
type="button"
data-slot="file-item-remove"
data-label={removeLabel}
aria-label={name ? `${removeLabel} ${name}` : removeLabel}
>
<slot name="remove"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12"></path>
</svg></slot
>
</button>
</li>
---
// src/components/ui/forms/file-upload/FileItemProgress.astro — FileUpload compound part (see ../../README.md).
// A native <progress> for one file's upload. Native because it already announces as a progress bar,
// already reports its value to assistive tech, and already has an indeterminate state — which is
// what you get by leaving `value` off, and what an upload looks like before the first byte lands.
//
// The library does not upload anything, so nothing here sets the value: that is the consumer's fetch
// or XHR, writing `progress.value = event.loaded / event.total`. This file exists so the element is
// in the right place with the right label when they do.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"progress"> & {
/** 0–1. Leave it off for the indeterminate bar an upload starts as. */
value?: number;
label?: string;
};
const { value, label = "Upload progress", class: className, ...rest } = Astro.props;
---
<progress
max="1"
value={value}
aria-label={label}
data-slot="file-item-progress"
class={className}
{...rest}></progress>
---
// src/components/ui/forms/file-upload/FileList.astro — FileUpload compound part (see ../../README.md).
// The list of chosen files, plus the <template> its rows are cloned from and the empty state.
//
// IT IS A LIVE REGION, and that is the part that earns the file. Dropping three files on a dropzone
// changes the page with no keyboard event and no focus move: without `aria-live` a screen reader
// user gets no confirmation that anything happened at all, and no way to discover it short of
// hunting. Polite, so it waits its turn.
//
// The template holds one FileItem — see that file on why the row's markup lives in Astro rather than
// in a string inside the script. Pass your own row instead by slotting it into `item`.
import type { HTMLAttributes } from "astro/types";
import FileItem from "./FileItem.astro";
type Props = HTMLAttributes<"div"> & {
label?: string;
emptyText?: string;
removeLabel?: string;
};
const {
label = "Chosen files",
emptyText = "No files chosen",
removeLabel = "Remove",
class: className,
...rest
} = Astro.props;
---
<div
role="region"
aria-live="polite"
aria-label={label}
data-slot="file-list-region"
class={className}
{...rest}
>
<ul data-slot="file-list"></ul>
<p data-slot="file-list-empty">{emptyText}</p>
<template data-slot="file-item-template">
<slot name="item"><FileItem removeLabel={removeLabel} /></slot>
</template>
</div>
---
// src/components/ui/forms/file-upload/FileTrigger.astro — FileUpload compound part (see ../../README.md).
// The button that opens the file picker. FileUpload's script points it at the input inside the same
// root, so it needs no `for` and no id to be minted anywhere.
//
// A <button> rather than a <label for>, deliberately. A label does open the picker on click — but
// the keyboard route to it is focusing the INPUT, and every design that uses this component hides
// that input. A button is focusable, is announced as a button, and works the same for both.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
};
const { variant = "outline", size = "md", class: className, ...rest } = Astro.props;
---
<button
type="button"
data-slot="file-trigger"
data-variant={variant}
data-size={size}
class={className}
{...rest}
>
<slot>Choose files</slot>
</button>
---
// src/components/ui/forms/file-upload/FileUpload.astro — headless primitive (see ../../README.md).
// Drag-and-drop file input, built so that the real <input type="file"> stays the source of truth:
// dropped files are written back into `input.files` through a DataTransfer, so the form submits them
// normally, `required` still applies, and a server sees an ordinary multipart upload. Nothing here
// uploads anything — that is the consumer's fetch, and this is the field it reads from.
//
// TWO THINGS THE BROWSER GETS WRONG ON ITS OWN, and they are the reason this file exists:
// 1. Drop a file on a page and the browser NAVIGATES to it, discarding the form. The only fix is
// `preventDefault()` on `dragover`, on the dropzone, before the drop ever happens.
// 2. `accept` filters the picker and nothing else. A dropped file has never been past it, so the
// check has to be done here — `matchesAccept` in `file-list.ts`, checked by its test.
//
// Compose the parts you want inside it:
//
// <FileUpload name="attachments" accept="image/*" multiple>
// <FileDropzone>Drop images here, or <FileTrigger /></FileDropzone>
// <FileList />
// </FileUpload>
//
// ponytail: the list is REBUILT whenever the file set changes, so a FileItemProgress you were
// driving is replaced along with its row. For an uploader that shows live progress, start the upload
// from the `change` event and re-find the row by its `data-name` — or upload one file at a time,
// which is what most consumers of this actually do.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
name?: string;
accept?: string;
multiple?: boolean;
/** Names the input itself; use a Label instead when there is one on screen. */
label?: string;
/** BCP-47 tag for the file sizes. Defaults to the browser's. */
locale?: string;
/** Shown when a chosen or dropped file does not match `accept`. */
rejectedText?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
name,
accept,
multiple = false,
label = "Choose files",
locale,
rejectedText = "Some files were not of an accepted type.",
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
---
<div data-slot="file-upload" data-locale={locale}>
<input
type="file"
name={name}
accept={accept}
multiple={multiple}
aria-label={label}
data-slot="file-upload-input"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
<slot />
<p data-slot="file-upload-error" role="alert" hidden>{rejectedText}</p>
</div>
<script>
import { onReadyOnce } from "../../_once";
import { formatSize, matchesAccept } from "./file-list";
function wire(root: HTMLElement) {
const input = root.querySelector<HTMLInputElement>('[data-slot="file-upload-input"]');
if (!input) return;
const list = root.querySelector<HTMLElement>('[data-slot="file-list"]');
const empty = root.querySelector<HTMLElement>('[data-slot="file-list-empty"]');
const template = root.querySelector<HTMLTemplateElement>('[data-slot="file-item-template"]');
const error = root.querySelector<HTMLElement>('[data-slot="file-upload-error"]');
const dropzone = root.querySelector<HTMLElement>('[data-slot="file-dropzone"]');
const locale = root.dataset.locale || undefined;
const current = () => [...(input.files ?? [])];
// Our own `commit()` re-dispatches `change`, so the handler below has to tell a person's action
// from the library's own write: only a person's raises or clears the rejection message.
let committing = false;
/** Write a file set back into the real input — the one place `input.files` is ever assigned. */
const commit = (files: File[]) => {
const data = new DataTransfer();
for (const file of files) data.items.add(file);
input.files = data.files;
committing = true;
input.dispatchEvent(new Event("change", { bubbles: true }));
committing = false;
};
/** Drop what `accept` does not allow. */
const sift = (files: File[]) => files.filter((file) => matchesAccept(file, input.accept));
/** Show the rejection message for THIS action, or clear it because this one was clean. */
const report = (rejected: number) => {
if (error) error.hidden = rejected === 0;
};
const render = () => {
const files = current();
if (empty) empty.hidden = files.length > 0;
if (!list || !template) return;
const row = template.content.querySelector('[data-slot="file-item"]');
if (!row) return;
list.replaceChildren();
for (const file of files) {
const item = row.cloneNode(true) as HTMLElement;
item.dataset.name = file.name;
const nameEl = item.querySelector('[data-slot="file-item-name"]');
const sizeEl = item.querySelector('[data-slot="file-item-size"]');
const remove = item.querySelector('[data-slot="file-item-remove"]');
if (nameEl) nameEl.textContent = file.name;
if (sizeEl) sizeEl.textContent = formatSize(file.size, locale);
// "Remove" five times over tells a screen reader user nothing about which row they are on,
// so the file's own name completes the button's name.
remove?.setAttribute(
"aria-label",
`${remove.getAttribute("data-label") ?? "Remove"} ${file.name}`,
);
list.append(item);
}
};
input.addEventListener("change", () => {
const files = current();
const kept = sift(files);
if (!committing) report(files.length - kept.length);
// A user can switch the picker's filter to "All files", so the check applies to chosen files
// too. Re-committing fires `change` again, and the second pass has nothing left to drop.
if (kept.length !== files.length) commit(kept);
else render();
});
root.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-slot="file-trigger"]')) input.click();
const remove = target.closest<HTMLElement>('[data-slot="file-item-remove"]');
if (!remove) return;
const name = remove.closest<HTMLElement>('[data-slot="file-item"]')?.dataset.name;
commit(current().filter((file) => file.name !== name));
input.focus(); // the row that had focus is gone; land somewhere predictable
});
if (dropzone) {
// THE `preventDefault` THAT MAKES A DROPZONE A DROPZONE. Without it the browser opens the
// dropped file as a page of its own and the form is gone.
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
dropzone.dataset.dragging = "true";
});
dropzone.addEventListener("dragleave", () => delete dropzone.dataset.dragging);
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
delete dropzone.dataset.dragging;
const files = [...(event.dataTransfer?.files ?? [])];
const kept = sift(files);
report(files.length - kept.length);
if (kept.length === 0) return;
commit(input.multiple ? [...current(), ...kept] : kept.slice(0, 1));
});
}
render();
}
onReadyOnce('[data-slot="file-upload"]', wire);
</script>
// src/components/ui/forms/file-upload/file-list.ts — the two rules FileUpload needs that a browser does
// not hand it: does this file match the `accept` attribute, and what do you call its size. Both are
// pure, so they are checkable without a DOM (see file-list.test.ts).
//
// WHY `matchesAccept` HAS TO EXIST. The `accept` attribute filters the file PICKER and nothing else:
// a file arriving by drag-and-drop has never been through it, so a dropzone that trusts `accept` to
// have done the work happily takes a .exe into an avatar field. The browser applies no check on drop
// at all, and the server should not be the first thing to notice.
/** The shape this module needs from a `File` — so the rule can be checked without one. */
export interface FileLike {
readonly name: string;
readonly type: string;
}
/**
* Whether `file` satisfies an `accept` attribute — the same three forms the picker understands:
* an extension (`.png`), an exact type (`image/png`), or a wildcard (`image/*`). An empty `accept`
* accepts everything, exactly as it does natively.
*
* @example matchesAccept({ name: "a.png", type: "image/png" }, "image/*,.pdf") // => true
*/
export function matchesAccept(file: FileLike, accept?: string | null): boolean {
if (!accept?.trim()) return true;
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return accept
.split(",")
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean)
.some((rule) => {
if (rule.startsWith(".")) return name.endsWith(rule);
if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
return type === rule;
});
}
const UNITS = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte"] as const;
/**
* A file size in words: `1.4 MB`. Localised through `Intl.NumberFormat`'s unit style, so the
* separator and the unit name follow the user's locale rather than a hard-coded "MB".
*
* Decimal thousands, not binary 1024s: it is what every operating system's file manager shows, and
* agreeing with the number beside the file on the user's own desktop matters more than being right
* about kibibytes.
*
* @example formatSize(1_400_000, "en-GB") // => "1.4 MB"
*/
export function formatSize(bytes: number, locale?: string): string {
const size = Math.max(0, bytes);
const step = size === 0 ? 0 : Math.min(UNITS.length - 1, Math.floor(Math.log10(size) / 3));
const value = size / 1000 ** step;
return new Intl.NumberFormat(locale, {
style: "unit",
unit: UNITS[step],
unitDisplay: "short",
maximumFractionDigits: step === 0 ? 0 : 1,
}).format(value);
}
import FileDropzone from "./FileDropzone.astro";
import FileItem from "./FileItem.astro";
import FileItemProgress from "./FileItemProgress.astro";
import FileList from "./FileList.astro";
import FileTrigger from "./FileTrigger.astro";
import FileUpload from "./FileUpload.astro";
export { FileDropzone, FileItem, FileItemProgress, FileList, FileTrigger, FileUpload };
export default FileUpload;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/file-upload — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.