Uploader
A drag-and-drop file field with per-file progress, replace, and error states.
npx shadcn@latest add @velaui/uploaderpnpm dlx velaui add @velaui/uploaderyarn dlx shadcn@latest add @velaui/uploaderbunx --bun shadcn@latest add @velaui/uploaderveni add uploaderInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/uploaderpnpm dlx velaui add @velaui/uploaderyarn dlx shadcn@latest add @velaui/uploaderbunx --bun shadcn@latest add @velaui/uploaderExamples
- Default
- Compact field
- Uploading
- Replaceable file
- Photo alert
- Multiple files
- Browse button
Usage
Props
Uploader
Takes every prop of UploaderProps.
UploaderDropzone
| Prop | Type | Default |
|---|---|---|
caption | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
extended | boolean | — |
onFilesDrop | (files: File[]) => void | — |
text | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
Takes these and every prop of UploaderDropzoneProps.
UploaderFileItem
| Prop | Type | Default |
|---|---|---|
action | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
caption | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
invalid | boolean | — |
namerequired | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
onRemove | () => void | — |
progress | number | — |
size | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
thumbnail | string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | — |
Takes these and every prop of UploaderFileItemProps.
UploaderInput
Takes every prop of UploaderInputProps.
UploaderMessage
Takes every prop of UploaderMessageProps.
UploaderTrigger
Takes every prop of UploaderTriggerProps.
Dependencies
- npm
class-variance-authority@0.7.1- Installs with it
- Button1 icon
Source
components/ui/uploader.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Close02 } from "@/components/icons/close-02";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
/**
* The dashed drop target. Both shapes share every state colour, so the state
* selectors live in the base string and `extended` carries measurements only.
* Focus is read off the file input inside it — the input is the focusable
* element, the label is what gets drawn.
*/
const uploaderDropzoneVariants = cva(
[
"relative flex w-full cursor-pointer items-center rounded-xl border border-dashed border-border-default text-icon-strong",
"hover:bg-bg-surface-1",
"data-[dragover=true]:border-border-active",
"has-[input:focus-visible]:border-border-active has-[input:focus-visible]:bg-bg-main has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring-neutral-shadow",
"aria-invalid:border-negative-middle",
].join(" "),
{
variants: {
extended: {
true: "flex-col justify-center gap-4 p-4 text-center [&_svg]:size-8",
false: "gap-2 px-4 py-3 [&_svg]:size-6",
},
},
defaultVariants: {
extended: true,
},
},
);
export type UploaderProps = React.ComponentProps<"div">;
function Uploader({ className, ...props }: UploaderProps) {
return (
<div
data-slot="uploader"
className={cn("flex w-full flex-col items-start gap-2", className)}
{...props}
/>
);
}
export type UploaderDropzoneProps = React.ComponentProps<"label"> &
VariantProps<typeof uploaderDropzoneVariants> & {
text?: React.ReactNode;
caption?: React.ReactNode;
onFilesDrop?: (files: File[]) => void;
};
/**
* Handles the OS file drop itself: it flips `data-dragover` while a drag is
* over it, and on drop it writes the files into the `UploaderInput` inside it
* so the native input — and any form around it — sees them exactly as if the
* user had picked them from the file dialog.
*
* Children are the icon and the input; the copy comes in as `text`/`caption`.
*/
function UploaderDropzone({
className,
children,
extended,
text,
caption,
onFilesDrop,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
...props
}: UploaderDropzoneProps) {
const ref = React.useRef<HTMLLabelElement>(null);
// dragenter/dragleave fire again for every child the pointer crosses, so the
// leave that matters is the one that brings the depth back to zero.
const depth = React.useRef(0);
const [dragover, setDragover] = React.useState(false);
function handOff(files: File[]) {
const input = ref.current?.querySelector<HTMLInputElement>(
'input[data-slot="uploader-input"]',
);
if (!input) return files;
const accepted = input.multiple ? files : files.slice(0, 1);
const transfer = new DataTransfer();
for (const file of accepted) transfer.items.add(file);
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
return accepted;
}
return (
<label
ref={ref}
data-slot="uploader-dropzone"
data-extended={extended ?? true}
data-dragover={dragover}
className={cn(uploaderDropzoneVariants({ extended }), className)}
onDragEnter={(event) => {
depth.current += 1;
setDragover(true);
onDragEnter?.(event);
}}
onDragOver={(event) => {
// Without this the browser opens the file instead of dropping it.
event.preventDefault();
onDragOver?.(event);
}}
onDragLeave={(event) => {
depth.current -= 1;
if (depth.current <= 0) {
depth.current = 0;
setDragover(false);
}
onDragLeave?.(event);
}}
onDrop={(event) => {
event.preventDefault();
depth.current = 0;
setDragover(false);
const dropped = Array.from(event.dataTransfer.files);
if (dropped.length) onFilesDrop?.(handOff(dropped));
onDrop?.(event);
}}
{...props}
>
{children}
{(text || caption) && (
<div className="flex min-w-0 flex-1 flex-col text-left in-data-[extended=true]:w-full in-data-[extended=true]:flex-none in-data-[extended=true]:gap-1 in-data-[extended=true]:text-center">
{text && (
<p className="text-sm leading-[1.4] font-semibold text-text-strong">
{text}
</p>
)}
{caption && (
<p className="text-xs leading-[1.3] text-text-note">{caption}</p>
)}
</div>
)}
</label>
);
}
/**
* The other field type: no dashed target, just the theme's secondary button.
* It is a `label`, not a `button`, so the click reaches the input inside it
* the same way the dropzone's does — which is also why the focus ring is read
* off that input rather than off this element.
*/
export type UploaderTriggerProps = React.ComponentProps<"label">;
function UploaderTrigger({
className,
...props
}: UploaderTriggerProps) {
return (
<label
data-slot="uploader-trigger"
className={cn(
buttonVariants({ variant: "secondary", size: "md" }),
"cursor-pointer has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring-neutral-shadow",
className,
)}
{...props}
/>
);
}
/** The native file input. Hidden, but still the thing that takes focus. */
export type UploaderInputProps = React.ComponentProps<"input">;
function UploaderInput({ className, ...props }: UploaderInputProps) {
return (
<input
type="file"
data-slot="uploader-input"
className={cn("sr-only", className)}
{...props}
/>
);
}
/** The helper line under the field — negative once the field is invalid. */
export type UploaderMessageProps = React.ComponentProps<"p">;
function UploaderMessage({ className, ...props }: UploaderMessageProps) {
return (
<p
data-slot="uploader-message"
className={cn(
"flex items-center gap-1 text-xs leading-[1.3] text-text-note aria-invalid:text-negative-middle [&_svg]:size-4",
className,
)}
{...props}
/>
);
}
/**
* One file in the list, in whichever state its props put it:
*
* - `progress` present → uploading, with the bar and its percentage;
* - `action` present → replaceable (a "Replace"/"Retry" control you supply,
* so it can be a button or a label around an input);
* - `invalid` → the alert card, which also swaps the detail order to
* message-then-size the way the design draws it.
*/
export type UploaderFileItemProps = Omit<React.ComponentProps<"div">, "children"> & {
name: React.ReactNode;
size?: React.ReactNode;
caption?: React.ReactNode;
thumbnail?: React.ReactNode;
action?: React.ReactNode;
progress?: number;
invalid?: boolean;
onRemove?: () => void;
};
function UploaderFileItem({
className,
name,
size,
caption,
thumbnail,
action,
progress,
invalid,
onRemove,
...props
}: UploaderFileItemProps) {
const details = invalid ? [caption, size] : [size, caption];
return (
<div
data-slot="uploader-file-item"
aria-invalid={invalid || undefined}
className={cn(
"group/file-item flex w-full flex-col gap-2 rounded-xl bg-bg-surface-1 p-4 aria-invalid:bg-negative",
progress !== undefined && "pb-3",
className,
)}
{...props}
>
<div className="flex w-full items-center gap-3">
{thumbnail && (
<div className="flex shrink-0 items-center justify-center [&_img]:size-9 [&_img]:rounded-lg [&_img]:object-cover [&_svg]:h-10 [&_svg]:w-8">
{thumbnail}
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex w-full items-center gap-2">
<p className="min-w-0 flex-1 truncate text-sm leading-[1.4] font-semibold text-text-strong">
{name}
</p>
{(action || onRemove) && (
<div className="flex h-5 shrink-0 items-center justify-end gap-2">
{action && (
<div className="[&>*]:inline-flex [&>*]:cursor-pointer [&>*]:items-center [&>*]:gap-1.5 [&>*]:rounded-lg [&>*]:text-sm [&>*]:leading-[1.4] [&>*]:font-semibold [&>*]:text-text-strong [&>*]:focus-visible:ring-3 [&>*]:focus-visible:ring-ring-neutral-shadow [&>*]:focus-visible:outline-none">
{action}
</div>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="inline-flex size-5 shrink-0 items-center justify-center rounded-xs text-icon-strong focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow focus-visible:outline-none [&_svg]:size-4"
>
<Close02 />
<span className="sr-only">Remove file</span>
</button>
)}
</div>
)}
</div>
{(size || caption) && (
<div className="flex w-full items-center gap-2 text-xs leading-[1.3] text-text-note group-aria-invalid/file-item:text-negative-strong">
{details[0] && (
<span
className={cn(
"flex items-center gap-1",
invalid && "text-negative-middle [&_svg]:size-4",
)}
>
{details[0]}
</span>
)}
{details[0] && details[1] && (
<span className="size-0.5 shrink-0 rounded-full bg-current" />
)}
{details[1] && (
<span className="min-w-0 flex-1 truncate">{details[1]}</span>
)}
</div>
)}
</div>
</div>
{progress !== undefined && (
<div className="flex h-6 max-h-6 min-h-6 w-full items-center gap-1">
<div className="flex min-w-0 flex-1 flex-col py-2">
<div
role="progressbar"
aria-label={`Upload ${name}`}
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-full overflow-hidden rounded-lg bg-bg-surface-1"
>
<div
className="h-full rounded-lg bg-bg-foreground"
style={{ width: `${progress}%` }}
/>
<span className="sr-only">{progress}% complete</span>
</div>
</div>
<span className="w-8 shrink-0 text-right text-sm leading-[1.4] text-text-strong">
{progress}%
</span>
</div>
)}
</div>
);
}
export {
Uploader,
UploaderDropzone,
UploaderFileItem,
UploaderInput,
UploaderMessage,
UploaderTrigger,
uploaderDropzoneVariants,
};