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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"li"> & {
name?: string;
size?: string;
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"progress"> & {
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>
---
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>
---
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
name?: string;
accept?: string;
multiple?: boolean;
label?: string;
locale?: string;
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 ?? [])];
let committing = false;
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;
};
const sift = (files: File[]) => files.filter((file) => matchesAccept(file, input.accept));
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?.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);
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();
});
if (dropzone) {
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>
export interface FileLike {
readonly name: string;
readonly type: string;
}
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;
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.