File Upload
View as MarkdownA file picker with drag and drop support, file lists, and validation hooks.
<ui:fileUpload.root name="attachments[]" maxFiles="5" accept="image/*,.pdf">
<ui:fileUpload.label>Attachments</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Drag files here or choose from your device</div>
<div class="text-muted-foreground text-sm">Images and PDFs up to 5 files.</div>
</div>
<ui:fileUpload.trigger>Choose files</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<f:comment>
Accepted/rejected files only ever exist as browser File objects, so the item markup is
authored once as a ui:template and cloned/populated client-side per file.
</f:comment>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match="image/*">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No files selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
import { mountAll } from 'fluid-primitives';
import { FileUpload } from 'fluid-primitives/file-upload';
mountAll('fileUpload', ({ props }) => {
const fileUpload = new FileUpload(props);
fileUpload.init();
return fileUpload;
});
Features
- Supports button-triggered file selection and drag-and-drop uploads
- Works with the
Fieldprimitive for form labels, descriptions, and validation - Supports accepted file types, file count limits, size limits, and directory selection
- Exposes accepted and rejected files through the client-side API
- Supports clearing files and deleting individual accepted files
- Lets already-persisted files sit in the same list as newly-picked ones, so an edit form needs only one file list
- Renders accepted/rejected file items from a Fluid-authored
itemTemplate, since the actualFileobjects only ever exist in the browser - optionally a completely separate one for rejected items - Supports multiple preview variants per item, for example an image preview with a generic file-extension fallback
- Exports
fileValue(), the identityFileUploadstamps onto every rendered item'sdata-value, so userland code can resolve a clicked/found item back to the realFileit represents - Replaces TYPO3's
f:form.uploadand integrates with Extbase's#[FileUpload]attribute out of the box
Installation
typo3 ui:add file-upload
Please copy the files manually from GitHub into your project.
Read more about installing Components and Primitives.
Examples
With Field
Use file upload inside Field for form semantics and validation messaging.
<ui:field.root name="resume" required="{true}">
<ui:fileUpload.root accept=".pdf">
<ui:fileUpload.label>Resume</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Upload your resume</div>
<div class="text-muted-foreground text-sm">PDF only, one file maximum.</div>
</div>
<ui:fileUpload.trigger>Choose PDF</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match="image/*">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No files selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
<ui:field.description>We use this for your application review.</ui:field.description>
<ui:field.error />
</ui:field.root>
Accepted and Rejected Files
Accepted and rejected files only ever exist as browser File objects, so they can never be part of the server-rendered markup. The item part is instead authored once inside an itemTemplate part inside ui:fileUpload.root, and the primitive clones/populates it for every accepted or rejected file - the same ui:template pattern the Combobox uses for asynchronously loaded results, just wrapped in a part so neither the stencil's internal name nor its context argument are something you need to type yourself. Rejected items use the itemError part to show why they were rejected, and can use an entirely different template (via itemTemplate's type prop, see Custom Item Layout below) when a rejected file shouldn't look like an accepted one at all.
<ui:fileUpload.root name="portfolio[]" accept="image/*" maxFileSize="1500000" maxFiles="2">
<ui:fileUpload.label>Portfolio Images</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Drop up to two images</div>
<div class="text-muted-foreground text-sm">Large files will appear in a rejected list below.</div>
</div>
<div class="flex items-center gap-2">
<ui:fileUpload.trigger>Select images</ui:fileUpload.trigger>
<ui:fileUpload.clearTrigger />
</div>
</ui:fileUpload.dropzone>
<f:comment>
One item template shared by both file lists below - the same markup renders an accepted or
a rejected item, styled by its `data-type` attribute.
</f:comment>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match="image/*">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<div class="grid gap-4 md:grid-cols-2">
<div class="grid gap-2">
<div class="text-sm font-medium">Accepted files</div>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No files selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</div>
<div class="grid gap-2">
<div class="text-sm font-medium">Rejected files</div>
<ui:fileUpload.itemGroup type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}">
<ui:fileUpload.emptyState type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}">No rejected files.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</div>
</div>
</ui:fileUpload.root>
Preview Variants
Declare multiple itemPreview parts inside the item template, each with a match MIME type pattern (e.g. image/*). The primitive shows the first matching variant for a given file and hides the rest, which makes an image preview plus a generic fallback icon possible without a custom render callback.
<ui:fileUpload.root name="attachments[]" maxFiles="5" accept="image/*,.pdf">
<ui:fileUpload.label>Attachments</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Drag files here or choose from your device</div>
<div class="text-muted-foreground text-sm">Images and PDFs up to 5 files.</div>
</div>
<ui:fileUpload.trigger>Choose files</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<f:comment>
Accepted/rejected files only ever exist as browser File objects, so the item markup is
authored once as a ui:template and cloned/populated client-side per file.
</f:comment>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match="image/*">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No files selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
Translations
FileUpload translates three labels: dropzone (the dropzone's aria-label), itemPreview (an accepted item's preview alt text), and deleteFile (an item's delete button aria-label). The last two are functions in zag-js ((file: File) => string), since they interpolate the file's name - but Fluid has no callbacks to hand over, and the File only exists in the browser anyway. So they're translated as plain strings containing a literal %fileName% placeholder, which the primitive substitutes with the real file name client-side:
<ui:fileUpload.root
translations="{itemPreview: 'Vorschau von %fileName%', deleteFile: 'Datei %fileName% entfernen'}"
></ui:fileUpload.root>
Use %fileName%, not {fileName} - Fluid's own inline array/object syntax already treats a bare {...} inside a string as a nested variable expression, so a curly-brace placeholder would silently get stripped from an override written this way. Omit itemPreview/deleteFile to keep the built-in translation, or set an entry to {false} to omit that aria-label/alt entirely. Per-locale overrides can also live in your own locallang.xlf and be read with f:translate instead.
Directory Upload
Enable directory selection in browsers that support webkitdirectory.
<ui:fileUpload.root name="assets[]" directory="{true}" maxFiles="20">
<ui:fileUpload.label>Asset Folder</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Select a directory</div>
<div class="text-muted-foreground text-sm">Useful for importing grouped assets or media folders.</div>
</div>
<ui:fileUpload.trigger>Choose folder</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match="image/*">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No files selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
Custom Item Layout
Nothing about itemTemplate requires reusing the default part layout - author whatever markup you want inside it, drop parts you don't need (here, itemSizeText is left out entirely), and read the client-side API directly for anything that isn't a part at all, like a running "x / 5 files selected" count.
Rejected items can use a second itemTemplate, given type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}", instead of reusing the accepted one - handy when a rejected file should look nothing like an accepted one, e.g. no preview at all. FileUpload falls back to the accepted itemTemplate for rejected items when no dedicated rejected one is given, so this is opt-in.
The counter is plain userland JS: a ui:ref written directly in the root's slot content (context="fileUpload", since it isn't part of the primitive's own template) is read back via FileUpload's own getElement(), in a small subclass that updates it after every render.
Item-level data with no corresponding part works the same way, but each rendered item first has to be found again. itemModifiedDate is a plain ui:ref inside the item template - context="fileUpload" is required here too (and not optional the way it might look from the counter above): ambient ui:ref resolution only reliably works for a primitive's own template body, or for hand-authored content with no other part rendered as a sibling first - itemModifiedDate sits right next to itemPreview/fileName, so it needs the explicit argument. The subclass's render() override then re-finds every rendered item via getElements('item'), reads each one's own itemModifiedDate child with getElement('itemModifiedDate', itemEl) - passing that specific item as the scope is what lets one getElement() call resolve correctly per file, the same way it already does for any other item-level part - matches its data-value back to a File via the exported fileValue() helper (the exact identity FileUpload itself stamps onto that attribute), and fills in the date. The same pattern works for any other per-item data a template needs.
<ui:fileUpload.root name="photos[]" accept="image/*" maxFiles="5" controlled="{true}" rootId="custom-layout">
<div class="flex items-center justify-between gap-4">
<ui:fileUpload.label>Event Photos</ui:fileUpload.label>
<div {ui:ref(name: 'filesCounter', context: 'fileUpload')} class="text-muted-foreground text-xs">0 / 5 files selected</div>
</div>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Drop photos here or choose from your device</div>
<div class="text-muted-foreground text-sm">Up to 5 images. File size is intentionally not shown here.</div>
</div>
<ui:fileUpload.trigger>Choose photos</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<f:comment>
Accepted items get their own compact layout - a larger preview, a bold name and a text
"Remove" trigger instead of the default icon button, and deliberately no file size.
</f:comment>
<f:comment>
`itemModifiedDate` has no corresponding part anywhere in FileUpload - it's a plain ui:ref,
populated entirely from userland JS (see CustomLayout.entry.ts), to test whether a consumer
can show item-level data the primitive itself knows nothing about.
</f:comment>
<primitives:fileUpload.itemTemplate>
<primitives:fileUpload.item class="flex items-center gap-3 rounded-lg border bg-background p-2 shadow-xs">
<ui:fileUpload.itemPreview match="image/*" class="size-14">
<ui:fileUpload.itemPreviewImage />
</ui:fileUpload.itemPreview>
<ui:fileUpload.itemPreview match=".*" class="size-14">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName class="truncate text-sm font-semibold" />
<div {ui:ref(name: 'itemModifiedDate', context: 'fileUpload')} class="text-muted-foreground text-[11px]"></div>
</div>
<primitives:fileUpload.itemDeleteTrigger class="button button-ghost text-muted-foreground h-8 px-2.5 text-xs">Remove</primitives:fileUpload.itemDeleteTrigger>
</primitives:fileUpload.item>
</primitives:fileUpload.itemTemplate>
<f:comment>
Rejected items use a completely different template - no preview at all, just the file name
and why it was rejected - and are listed in their own group below the accepted photos.
</f:comment>
<primitives:fileUpload.itemTemplate type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}">
<primitives:fileUpload.item class="border-destructive/30 bg-destructive/5 grid gap-0.5 rounded-lg border px-3 py-2">
<div class="flex items-center justify-between gap-3">
<ui:fileUpload.fileName class="truncate text-sm font-medium" />
<primitives:fileUpload.itemDeleteTrigger class="button button-ghost text-muted-foreground h-7 px-2 text-xs">Dismiss</primitives:fileUpload.itemDeleteTrigger>
</div>
<ui:fileUpload.fileError />
</primitives:fileUpload.item>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No photos selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
<div class="grid gap-2">
<div class="text-sm font-medium">Rejected</div>
<ui:fileUpload.itemGroup type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}">
<ui:fileUpload.emptyState type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}">Nothing was rejected.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</div>
</ui:fileUpload.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/ui/FileUpload/Examples/CustomLayout.entry.ts" />
import { mount } from 'fluid-primitives';
import { FileUpload, fileValue } from 'fluid-primitives/file-upload';
function findFileByValue(files: File[], value: string): File | undefined {
return files.find(file => fileValue(file) === value);
}
class FileUploadWithCounter extends FileUpload {
render() {
super.render();
this.updateFilesCounter();
this.updateItemModifiedDates();
}
private updateFilesCounter() {
const counterEl = this.getElement<HTMLElement>('filesCounter');
if (!counterEl) return;
counterEl.textContent = `${this.api.acceptedFiles.length} / ${this.machine.prop('maxFiles')} files selected`;
}
/**
* `itemModifiedDate` isn't a part FileUpload knows about at all - it's a plain ui:ref inside
* the item template (see CustomLayout.html), populated entirely here. render() already reruns
* on every accepted/rejected-files change (see Component.init()), which is also exactly when
* new item clones need this filled in, so no separate wiring is needed beyond this loop.
* getElement(part, itemEl) - scoped to one specific item - finds it despite there being one
* per rendered file: Template's clone-time restamping (see ComponentHydrator.restampValue())
* gives every ref'd element with an id a unique one per file, the same way itemName/
* itemSizeText/itemPreview already get theirs, so no special handling is needed here either.
*/
private updateItemModifiedDates() {
this.getElements<HTMLElement>('item').forEach(itemEl => {
const dateEl = this.getElement<HTMLElement>('itemModifiedDate', itemEl);
const value = itemEl.dataset.value;
if (!dateEl || !value) return;
const file = findFileByValue(this.api.acceptedFiles, value);
if (!file) return;
dateEl.textContent = `Modified ${new Date(file.lastModified).toLocaleDateString()}`;
});
}
}
mount('fileUpload', 'custom-layout', ({ props }) => {
const fileUpload = new FileUploadWithCounter(props);
fileUpload.init();
return fileUpload;
});
Confirm File Deletion
itemDeleteTrigger always deletes immediately once clicked - there's no built-in confirmation step, since what "confirm" means (a dialog, an undo toast, nothing) is entirely up to the consumer. This example pairs FileUpload with Dialog to ask before a file is actually removed: both are mounted as independent, hydration-controlled instances (controlled="{true}" + a fixed rootId, the same pattern Combobox's custom filter example uses), and a capturing-phase click listener on the item group intercepts the delete trigger's own click before it reaches FileUpload's built-in handler - event.stopImmediatePropagation() keeps the immediate delete from ever running. The file itself is recovered from the clicked item's data-value via the exported fileValue() helper (see Custom Item Layout above), and only deleted (via api.deleteFile()) once the dialog is confirmed.
Delegation is needed here because FileUpload re-clones its item markup on every accepted/rejected-files change, so there's no stable per-item element to attach a listener to directly.
<ui:fileUpload.root name="documents[]" accept=".pdf,.doc,.docx" maxFiles="5" controlled="{true}" rootId="delete-confirm">
<ui:fileUpload.label>Documents</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<div class="grid gap-1">
<div class="text-sm font-medium">Drop documents here or choose from your device</div>
<div class="text-muted-foreground text-sm">Removing a file asks for confirmation first.</div>
</div>
<ui:fileUpload.trigger>Choose files</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<ui:fileUpload.itemPreview match=".*">
<ui:fileUpload.filePreviewFallback />
</ui:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No documents selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
<f:comment>
A second, independently-controlled Dialog instance, opened programmatically from the entry file
below instead of its own ui:dialog.trigger - clicking an item's delete button never deletes
immediately, it opens this confirmation first.
</f:comment>
<ui:dialog.root controlled="{true}" rootId="delete-confirm" role="alertdialog">
<ui:dialog.content>
<ui:dialog.header>
<ui:dialog.title>Remove file?</ui:dialog.title>
<ui:dialog.description>
<span {ui:ref(name: 'pendingFileName', context: 'dialog')}>This file</span> will be removed from the upload. This can't be undone.
</ui:dialog.description>
</ui:dialog.header>
<ui:dialog.footer>
<ui:dialog.close asChild="{true}">
<ui:button variant="secondary">Cancel</ui:button>
</ui:dialog.close>
<button type="button" {ui:ref(name: 'confirmTrigger', context: 'dialog')} class="button button-default h-8 px-3.5 py-2.5">Remove file</button>
</ui:dialog.footer>
</ui:dialog.content>
</ui:dialog.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/ui/FileUpload/Examples/DeleteConfirmation.entry.ts" />
import { mountAll } from 'fluid-primitives';
import { FileUpload } from 'fluid-primitives/file-upload';
mountAll('fileUpload', ({ props }) => {
const fileUpload = new FileUpload(props);
fileUpload.init();
return fileUpload;
});
Using with Extbase
FileUpload produces a plain, native <input type="file"> under the hood, so it works with Extbase's #[FileUpload] property attribute without any special controller code - it replaces f:form.upload one-to-one.
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Domain\Model;
use TYPO3\CMS\Extbase\Attribute\FileUpload;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
class Conference extends AbstractEntity
{
#[FileUpload(
validation: [
'required' => false,
'maxFiles' => 1,
'fileSize' => ['minimum' => '10K', 'maximum' => '2M'],
'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
],
uploadFolder: '1:/user_upload/conference_logos/',
)]
protected ?FileReference $logo = null;
/**
* @var ObjectStorage<FileReference>
*/
#[FileUpload(
validation: [
'fileSize' => ['minimum' => '10K', 'maximum' => '10M'],
'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
],
uploadFolder: '1:/user_upload/conference_impressions/',
)]
protected ObjectStorage $impressions;
// constructor, initializeObject(), getters and setters...
}
Use ui:form.root, and set maxFiles on FileUpload to match the property's cardinality - 1 for a single FileReference, greater than 1 for an ObjectStorage<FileReference>. For a multi-file property, write the [] suffix on name yourself, the same way you would for a CheckboxGroup field bound to an array property - the primitive never appends it for you:
<ui:form.root action="update" objectName="conference" object="{conference}">
<ui:field.root name="logo">
<ui:fileUpload.root name="logo" accept="image/jpeg,image/png" maxFiles="1">
<ui:fileUpload.label>Logo</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<ui:fileUpload.trigger>Choose logo</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No logo selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
</ui:field.root>
<ui:field.root name="impressions">
<ui:fileUpload.root name="impressions[]" accept="image/jpeg,image/png" maxFiles="10">
<ui:fileUpload.label>Impressions</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<ui:fileUpload.trigger>Choose images</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<ui:fileUpload.itemGroup>
<ui:fileUpload.emptyState>No images selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
</ui:field.root>
</ui:form.root>
No enctype is needed on the form: Form never submits natively - it always posts a FormData body via fetch (see the Forms guide), and fetch always sends a FormData body as multipart/form-data regardless of any enctype attribute, since it isn't reading the <form> element at all.
Keep validation in sync
Keep the allowed MIME types/extensions in #[FileUpload] in sync with the TCA type=file 'allowed' key on the same column, and pass the same list to FileUpload's accept prop so the browser's file picker pre-filters accordingly. accept is a client-side hint only, never a security control.
Editing: Mixing Already-Uploaded Files With New Ones
FileUpload's own file list only ever holds files newly picked in the browser - it has no concept of an already-persisted FileReference from a previous request. For an edit form, render existing files as ordinary items directly inside ui:fileUpload.itemGroup, alongside the newly-picked ones, instead of showing them in a second, separate list. Give each one type="existing" and pair its delete trigger with ui:fileUploadDeleteCheckbox, which renders TYPO3's HMAC-signed @delete token so Extbase removes the file reference on submit:
Existing items still sit inside the same itemTemplate-driven itemGroup as newly-picked files (see Accepted and Rejected Files above), so don't drop the item template when adding them - without it, the itemGroup renders the existing files fine, but selecting a new file has nothing to clone and populate, and throws:
<ui:form.root action="update" objectName="conference" object="{conference}">
<ui:field.root name="impressions[]">
<ui:fileUpload.root
accept="image/jpeg,image/png"
maxFiles="10"
existingFilesCount="{conference.impressions -> f:count()}"
>
<ui:fileUpload.label>Impressions</ui:fileUpload.label>
<ui:fileUpload.dropzone>
<ui:fileUpload.trigger>Choose images</ui:fileUpload.trigger>
</ui:fileUpload.dropzone>
<primitives:fileUpload.itemTemplate>
<ui:fileUpload.fileItem>
<primitives:fileUpload.itemPreview
match="image/*"
class="bg-muted flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border"
>
<primitives:fileUpload.itemPreviewImage class="size-full object-cover" />
</primitives:fileUpload.itemPreview>
<primitives:fileUpload.itemPreview
match=".*"
class="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border"
>
<ui:fileUpload.filePreviewFallback />
</primitives:fileUpload.itemPreview>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName />
<ui:fileUpload.fileMeta />
<ui:fileUpload.fileError />
</div>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</primitives:fileUpload.itemTemplate>
<ui:fileUpload.itemGroup>
<f:for each="{conference.impressions}" as="fileReference">
<ui:fileUpload.fileItem type="existing">
<f:image
image="{fileReference}"
width="40"
height="40"
class="size-10 shrink-0 rounded-md border object-cover"
/>
<div class="grid min-w-0 flex-1 gap-0.5">
<ui:fileUpload.fileName
>{fileReference.originalResource.name}</ui:fileUpload.fileName
>
</div>
<ui:fileUploadDeleteCheckbox
fileReference="{fileReference}"
class="hidden"
/>
<ui:fileUpload.fileDeleteTrigger />
</ui:fileUpload.fileItem>
</f:for>
<ui:fileUpload.emptyState>No images selected yet.</ui:fileUpload.emptyState>
</ui:fileUpload.itemGroup>
</ui:fileUpload.root>
</ui:field.root>
</ui:form.root>
Clicking that delete trigger checks the sibling checkbox and hides the item immediately, exactly like removing a newly-picked file - the actual FileReference is only deleted once the form is submitted. The checkbox must be a sibling of fileDeleteTrigger, never nested inside it: a native <button> isn't allowed to contain interactive descendants like <input>, so class="hidden" (rather than nesting) is how it's kept out of view.
ui:fileUploadDeleteCheckbox requires the enclosing ui:form.root to have an objectName matching the controller action's argument name, and reads property from the surrounding ui:field.root when not given explicitly.
existingFilesCount tells the machine how many slots the existing items above already use, so maxFiles and the rejected-as-TOO_MANY_FILES behavior account for them too. It's deliberately a plain count, not the existing files themselves: whatever the machine holds as an accepted file also ends up in the hidden input's native FileList and gets resubmitted on the next form post - passing the existing FileReferences in as reconstructed File placeholders would silently overwrite each one with garbage content built from just its name and size.
API Reference
The following tables cover the available props of the Fluid Primitives. For a full list of available client side props and methods, see the Zag.js Machine API.
fileUpload.root
Contains every part of the file upload. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
name | stringThe name of the underlying file input. Inherited from a surrounding field when available. | No | - |
disabled | booleanWhether the file upload is disabled. Inherited from a surrounding field when available. | No | - |
invalid | booleanWhether the file upload is invalid. Inherited from a surrounding field when available. | No | - |
required | booleanWhether the file upload is required. Inherited from a surrounding field when available. | No | - |
readOnly | booleanWhether the file upload is read-only. Inherited from a surrounding field when available. | No | - |
accept | mixedThe accepted file types, e.g. `image/*` or `{ 'image/png': ['.png'] }`. | No | - |
allowDrop | booleanWhether to allow drag and drop in the dropzone element. | No | true |
maxFiles | integerThe maximum number of files that can be selected. | No | 1 |
existingFilesCount | integerThe number of already-persisted files shown outside the machine's own file list (e.g. as `type="existing"` items), so it can be subtracted from `maxFiles` and the remaining-slots count also accounts for them. Deliberately a plain count, not a list of `File` objects: whatever ends up in the machine's own accepted files also ends up in the hidden input's `FileList` and gets resubmitted on the next form post, which would silently overwrite an already-stored file with placeholder content reconstructed from just its name/size. | No | 0 |
maxFileSize | integerThe maximum file size in bytes. | No | - |
minFileSize | integerThe minimum file size in bytes. | No | - |
preventDocumentDrop | booleanWhether to prevent a file drop outside of the dropzone from navigating the document. | No | true |
capture | stringThe camera to use to capture media on mobile devices. Either `user` or `environment`. | No | - |
directory | booleanWhether to accept whole directories instead of files. Only works in webkit based browsers. | No | false |
translations | arrayLocalized file upload labels. Set entries to `` to omit the corresponding `aria-label`. Use `f:translate` for per-template localization overrides when needed. | No | - |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
rootId | stringThe root ID of the component, used for hydration and identification. | No | - |
ids | arrayThe IDs of of the component parts for composition. | No | [] |
controlled | booleanIf true, the component is meant to be initialized manually inside another component | No | false |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | root |
data-disabled | Present when disabled |
data-readonly | Present when read-only |
data-dragging | Present when in the dragging state |
fileUpload.label
The label for the file input. Renders a <label> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | label |
data-disabled | Present when disabled |
data-required | Present when required |
fileUpload.dropzone
The drag-and-drop target and click-to-open surface. Renders a <div> element with role="button".
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | dropzone |
data-invalid | Present when invalid |
data-disabled | Present when disabled |
data-readonly | Present when read-only |
data-dragging | Present when in the dragging state |
fileUpload.hiddenInput
The native file input used for form submission. Renders a visually hidden <input type="file"> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fileUpload.trigger
Opens the file picker dialog. Renders a <button> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | trigger |
data-disabled | Present when disabled |
data-readonly | Present when read-only |
data-invalid | Present when invalid |
fileUpload.itemGroup
Groups accepted or rejected file items, selected via the type prop (Jramke\FluidPrimitives\Enum\FileUploadItemType). Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
type | Enum\FileUploadItemTypeWhich file list this group renders, `accepted` or `rejected`. | No | Accepted |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-group |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.emptyState
Shown while an itemGroup has no items yet. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
type | Enum\FileUploadItemTypeWhich file list this empty state belongs to - must match the enclosing item group's own `type`, to keep ids unique when both groups render in the same root. | No | Accepted |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fileUpload.itemTemplate
Wraps the item markup for accepted files, or rejected files when type is set to Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected (falls back to the accepted template when no dedicated rejected one is given). Renders a <template> element - never visible itself, cloned per file.
| Name | Description | Required | Default |
|---|---|---|---|
type | Enum\FileUploadItemTypeWhich files this template is for. `accepted` (the default) also doubles as the fallback rejected items use when no dedicated `rejected` template is given - see FileUpload.ts's resolveItemTemplatePart(). | No | Accepted |
fileUpload.item
One file. Accepted/rejected items are only ever rendered from an itemTemplate; an already-persisted file can be authored directly with type="existing". Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
type | stringThe item's kind, e.g. `accepted`, `rejected`, or a custom value such as `existing` for an already-persisted file authored directly rather than populated from a `ui:template` stencil. | No | 'accepted' |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.itemName
Displays the file's name. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-name |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.itemSizeText
Displays the file's formatted size. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-size-text |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.itemError
Displays a rejected file's validation errors, hidden by default. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fileUpload.itemPreview
A preview variant for an item, shown when its match MIME type pattern matches the file. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
match | stringA file MIME type pattern (e.g. `image/*`) this preview variant should be used for. The first `itemPreview` whose `match` matches the file wins; use `.*` as a catch-all fallback. | No | '.*' |
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-preview |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.itemPreviewImage
Displays an image preview, only rendered for image files. Renders an <img> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-preview-image |
data-disabled | Present when disabled |
data-type | The type of the item |
fileUpload.itemPreviewFallback
Displays a generic fallback (e.g. the file extension) inside a non-image itemPreview variant. Renders a <div> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
fileUpload.itemDeleteTrigger
Removes a single accepted or rejected file. Renders a <button> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | item-delete-trigger |
data-disabled | Present when disabled |
data-readonly | Present when read-only |
data-type | The type of the item |
fileUpload.clearTrigger
Clears every accepted file. Renders a <button> element.
| Name | Description | Required | Default |
|---|---|---|---|
asChild | booleanIf true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. | No | - |
class | stringThe CSS class(es) to be applied to the component. | No | - |
attributes | arrayAdditional attributes that should be rendered on the component where ui:attributes is used. | No | [] |
Rendered data attributes
| Attribute | Description |
|---|---|
data-scope | file-upload |
data-part | clear-trigger |
data-disabled | Present when disabled |
data-readonly | Present when read-only |
Machine JavaScript API
| Name | Type | Description |
|---|---|---|
dragging | boolean | Whether the user is dragging something over the root element |
focused | boolean | Whether the user is focused on the dropzone element |
disabled | boolean | Whether the file input is disabled |
readOnly | boolean | Whether the file input is in read-only mode |
transforming | boolean | Whether files are currently being transformed via `transformFiles` |
maxFilesReached | boolean | Whether the maximum number of files has been reached |
remainingFiles | number | The number of files that can still be added |
openFilePicker | VoidFunction | Function to open the file dialog |
deleteFile | (file: File, type?: ItemType | undefined) => void | Function to delete the file from the list |
acceptedFiles | File[] | The accepted files that have been dropped or selected |
rejectedFiles | FileRejection[] | The files that have been rejected |
setFiles | (files: File[]) => void | Sets the accepted files |
clearFiles | VoidFunction | Clears the accepted files |
clearRejectedFiles | VoidFunction | Clears the rejected files |
getFileSize | (file: File) => string | Returns the formatted file size (e.g. 1.2MB) |
createFileUrl | (file: File, cb: (url: string) => void) => VoidFunction | Returns the preview url of a file. Returns a function to revoke the url. |
setClipboardFiles | (dt: DataTransfer | null) => boolean | Sets the clipboard files Returns `true` if the clipboard data contains files, `false` otherwise. |
Anatomy
<primitives:fileUpload.root>
<primitives:fileUpload.label />
<primitives:fileUpload.dropzone>
<primitives:fileUpload.trigger />
</primitives:fileUpload.dropzone>
<primitives:fileUpload.hiddenInput />
<primitives:fileUpload.itemTemplate>
<primitives:fileUpload.item>
<primitives:fileUpload.itemPreview match="image/*">
<primitives:fileUpload.itemPreviewImage />
</primitives:fileUpload.itemPreview>
<primitives:fileUpload.itemPreview match=".*">
<primitives:fileUpload.itemPreviewFallback />
</primitives:fileUpload.itemPreview>
<primitives:fileUpload.itemName />
<primitives:fileUpload.itemSizeText />
<primitives:fileUpload.itemError />
<primitives:fileUpload.itemDeleteTrigger />
</primitives:fileUpload.item>
</primitives:fileUpload.itemTemplate>
<f:comment>Optional - falls back to the itemTemplate above when omitted.</f:comment>
<primitives:fileUpload.itemTemplate
type="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FileUploadItemType::Rejected')}"
>
<primitives:fileUpload.item>
<primitives:fileUpload.itemName />
<primitives:fileUpload.itemError />
<primitives:fileUpload.itemDeleteTrigger />
</primitives:fileUpload.item>
</primitives:fileUpload.itemTemplate>
<primitives:fileUpload.itemGroup>
<primitives:fileUpload.emptyState />
</primitives:fileUpload.itemGroup>
<primitives:fileUpload.clearTrigger />
</primitives:fileUpload.root>