File Upload
The uploading experience: a drag-and-drop dropzone, a browse trigger, import shortcuts and built-in accept, size and count validation. Pairs with File Card for the resulting rows and per-file progress.
Installation#
Usage#
File Upload owns selection: it emits accepted files through onFiles and rejections through onReject, and you own the list. Render the resulting rows with File Card inside a FileUploadList - the two compose into the full experience.
import {
FileUpload,
FileUploadDropzone,
FileUploadIcon,
FileUploadTitle,
FileUploadLink,
FileUploadTrigger,
FileUploadList,
formatBytes,
} from "@/components/ui/file-upload"
import {
FileCard,
FileCardIcon,
FileCardContent,
FileCardName,
FileCardMeta,
fileTypeFromName,
} from "@/components/ui/file-card"
export function Uploader() {
const [files, setFiles] = React.useState<File[]>([])
return (
<FileUpload multiple onFiles={(f) => setFiles((prev) => [...prev, ...f])}>
<FileUploadDropzone>
<FileUploadIcon />
<FileUploadTitle>
Drag and drop your files, or <FileUploadLink />
</FileUploadTitle>
<FileUploadTrigger className="w-full">Upload files</FileUploadTrigger>
</FileUploadDropzone>
<FileUploadList>
{files.map((file, i) => (
<FileCard key={i}>
<FileCardIcon type={fileTypeFromName(file.name)} />
<FileCardContent>
<FileCardName>{file.name}</FileCardName>
<FileCardMeta>{formatBytes(file.size)}</FileCardMeta>
</FileCardContent>
</FileCard>
))}
</FileUploadList>
</FileUpload>
)
}Anatomy#
The FileUpload root holds a hidden file input and the shared state; the FileUploadDropzone is the dashed drop surface, filled with a FileUploadIcon, a FileUploadTitle with the FileUploadLink sitting inline in the sentence, and a FileUploadTrigger (or a FileUploadSources row).
The panel is a drop target, not a button. Clicking anywhere on it still opens the picker, but the keyboard path runs through the real controls inside it, so every dropzone must compose a FileUploadLink or a FileUploadTrigger. A role="button" panel wrapping those would be a nested-interactive violation and would swallow their Enter/Space.
Import shortcuts#
The local disk isn’t the only place a file lives. Introduce a FileUploadSources row with a labelled Divider and give each provider a FileUploadSource tile. A source owns its own picker - an OAuth flow, a provider SDK, your own modal - so it takes a onClick and deliberately does not route back into the native file dialog.
The mark is children, not a brand prop: pass whatever you own - a real logo, an img, or a Phosphor glyph.
Thumbnail layout#
When the upload is the picture, a filename row gets in the way. Swap FileUploadList for FileUploadGrid and give each File Card layout="tile": the thumbnail fills the square and FileCardActions become a scrim that fades in on hover or keyboard focus. The tile never moves - the photo and the scrim carry the whole interaction.
Row states#
A row in flight reads either way. Put the bar under the name with <FileCardProgress hideValue /> and move the percentage out to a FileCardValue on the right edge; or drop the bar for a plain FileCardMeta line when the pause and cancel controls matter more than the number. Once it lands, FileCardStatus reads the card’s state and lights the check on its own.
Browse button#
No room for a dropzone? Drop a FileUploadTrigger on its own - it opens the same picker, so an attach action is a single button. The trigger reuses the DS Button, so it inherits every variant, size, and the press scale.
Avatar picker#
Compose the trigger with an Avatar for a profile-photo picker: a single-file image/* upload that swaps the avatar in place, with a “Change photo” / “Remove” pair.
Validation#
Pass accept, maxSize (bytes), and multiple, and dropped or picked files are split for you: onFiles gets the keepers, onReject gets the rest with a reason (type, size, or count) so you can surface a precise message.
Density#
Density is Koala’s cross-cutting spacing axis (see Density). For File Upload it tunes the dropzone padding, the rhythm between its blocks, and the glyph. Density resolves prop → nearest DensityProvider → compact, so an unset dropzone renders compact; ask for comfortable to get the roomy panel.
Disabled#
Pass disabled to the root and the whole control goes inert - the dropzone stops accepting drops and clicks, the link, trigger and sources disable, and the surface dims.
API reference#
FileUpload forwards div props and adds accept, multiple, maxSize (bytes), disabled, density (comfortable | compact), onFiles(files), and onReject(rejections) (each rejection is { file, reason }).
FileUploadDropzone forwards div props and renders the drop surface. FileUploadLink and FileUploadTrigger both open the picker - the first as inline sentence text, the second as a Button (defaulting to outline). FileUploadSource takes a label plus the provider mark as children, and forwards button props. FileUploadIcon, FileUploadTitle, FileUploadDescription, FileUploadSources, FileUploadList, and FileUploadGrid forward their element props. The helper formatBytes(bytes) renders a human file size for the meta line. Every part accepts className, merged last.