Vela UI Docsvenizia.ai

Uploader

A drag-and-drop file field with per-file progress, replace, and error states.

npx shadcn@latest add @velaui/uploader
npx shadcn@latest add @velaui/uploader

Examples

  • Default
  • Compact field
  • Uploading
  • Replaceable file
  • Photo alert
  • Multiple files
  • Browse button

View code

Uploader · Default

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-default.tsx
import * as React from "react";
import {
  Uploader,
  UploaderDropzone,
  UploaderFileItem,
  UploaderInput,
  UploaderMessage,
} from "@/components/ui/uploader";
import { Label } from "@/components/ui/label";
import { Upload01 } from "@/components/icons/upload-01";

const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;

/** Live: drop files onto the field, or click it to browse. */
export default function Example() {
  const [files, setFiles] = React.useState<File[]>([]);

  return (
    <Uploader className="w-80">
      <Label htmlFor="upload-default" className="text-xs text-text-muted">
        Label
      </Label>
      <UploaderDropzone
        htmlFor="upload-default"
        text="Drag & Drop or Choose file to upload"
        caption="JPG, GIF or PNG. Max size of 800K"
        onFilesDrop={setFiles}
      >
        <Upload01 />
        <UploaderInput
          id="upload-default"
          multiple
          onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
        />
      </UploaderDropzone>
      {files.map((file) => (
        <UploaderFileItem
          key={file.name}
          name={file.name}
          size={mb(file.size)}
          onRemove={() =>
            setFiles((current) => current.filter((f) => f !== file))
          }
        />
      ))}
      <UploaderMessage>Message text</UploaderMessage>
    </Uploader>
  );
}

View code

Uploader · Compact field

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-compact-field.tsx
import * as React from "react";
import {
  Uploader,
  UploaderDropzone,
  UploaderFileItem,
  UploaderInput,
} from "@/components/ui/uploader";
import { Upload01 } from "@/components/icons/upload-01";

const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;

/**
 * The compact field: one row, smaller icon, no caption. Single-file, so a drop
 * of several keeps only the first — the native input decides that, not the demo.
 */
export default function Example() {
  const [file, setFile] = React.useState<File | null>(null);

  return (
    <Uploader className="w-80">
      <UploaderDropzone
        htmlFor="upload-compact"
        extended={false}
        text="Drag & Drop or Choose file to upload"
        onFilesDrop={(dropped) => setFile(dropped[0] ?? null)}
      >
        <Upload01 />
        <UploaderInput
          id="upload-compact"
          onChange={(event) => setFile(event.target.files?.[0] ?? null)}
        />
      </UploaderDropzone>
      {file && (
        <UploaderFileItem
          name={file.name}
          size={mb(file.size)}
          onRemove={() => setFile(null)}
        />
      )}
    </Uploader>
  );
}

View code

Uploader · Uploading

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-uploading.tsx
import { Uploader, UploaderFileItem } from "@/components/ui/uploader";

/** A file in flight — `progress` draws the bar and tightens the padding. */
export default function Example() {
  return (
    <Uploader className="w-80">
      <UploaderFileItem
        name="annual-report.pdf"
        size="1,2MB"
        caption="7 seconds left"
        progress={75}
        onRemove={() => {}}
      />
    </Uploader>
  );
}

View code

Uploader · Replaceable file

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-replaceable-file.tsx
import {
  Uploader,
  UploaderFileItem,
  UploaderMessage,
} from "@/components/ui/uploader";
import { File02 } from "@/components/icons/file-02";

/** Uploaded, with a file-type thumbnail and a Replace action. */
export default function Example() {
  return (
    <Uploader className="w-80">
      <UploaderFileItem
        name="contract-v3.pdf"
        size="1,2MB"
        thumbnail={<File02 className="text-negative-middle" />}
        action={<button type="button">Replace</button>}
        onRemove={() => {}}
      />
      <UploaderMessage>Message text</UploaderMessage>
    </Uploader>
  );
}

View code

Uploader · Photo alert

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-photo-alert.tsx
import { Uploader, UploaderFileItem } from "@/components/ui/uploader";
import { AlertCircle } from "@/components/icons/alert-circle";

const photo =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'%3E%3Crect width='36' height='36' fill='%23a3a3a3'/%3E%3C/svg%3E";

/** A failed upload: `invalid` turns the card negative and puts the message first. */
export default function Example() {
  return (
    <Uploader className="w-80">
      <UploaderFileItem
        invalid
        name="cover-photo.png"
        size="1,2MB"
        caption={
          <>
            <AlertCircle />
            Upload failed
          </>
        }
        thumbnail={<img src={photo} alt="" />}
        action={<button type="button">Retry</button>}
        onRemove={() => {}}
      />
    </Uploader>
  );
}

View code

Uploader · Multiple files

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-multiple-files.tsx
import {
  Uploader,
  UploaderDropzone,
  UploaderFileItem,
  UploaderInput,
} from "@/components/ui/uploader";
import { Upload01 } from "@/components/icons/upload-01";

const done = ["moodboard.png", "brand-guide.pdf"];

/** The whole flow: the field stays put while the list grows under it. */
export default function Example() {
  return (
    <Uploader className="w-80">
      <UploaderDropzone
        htmlFor="upload-many"
        text="Drag & Drop or Choose file to upload"
        caption="JPG, GIF or PNG. Max size of 800K"
      >
        <Upload01 />
        <UploaderInput id="upload-many" multiple />
      </UploaderDropzone>
      <UploaderFileItem
        name="site-audit.csv"
        size="1,2MB"
        caption="7 seconds left"
        progress={75}
        onRemove={() => {}}
      />
      {done.map((name) => (
        <UploaderFileItem
          key={name}
          name={name}
          size="1,2MB"
          onRemove={() => {}}
        />
      ))}
    </Uploader>
  );
}

View code

Uploader · Browse button

Installation

pnpm dlx velaui add @velaui/uploader
pnpm dlx velaui add @velaui/uploader

Code

uploader-browse-button.tsx
import * as React from "react";
import {
  Uploader,
  UploaderFileItem,
  UploaderInput,
  UploaderMessage,
  UploaderTrigger,
} from "@/components/ui/uploader";
import { Label } from "@/components/ui/label";
import { File02 } from "@/components/icons/file-02";

const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;

/**
 * The Browse Button type: no drop target, the button opens the file dialog.
 * Once a file is picked the button gives way to the item, whose Replace action
 * is the same label-around-an-input trick.
 */
export default function Example() {
  const [file, setFile] = React.useState<File | null>(null);
  const pick = (event: React.ChangeEvent<HTMLInputElement>) =>
    setFile(event.target.files?.[0] ?? null);

  return (
    <Uploader className="w-80">
      <Label className="text-xs text-text-muted">Label</Label>
      {file ? (
        <UploaderFileItem
          name={file.name}
          size={mb(file.size)}
          thumbnail={<File02 className="text-negative-middle" />}
          action={
            <label>
              Replace
              <UploaderInput onChange={pick} />
            </label>
          }
          onRemove={() => setFile(null)}
        />
      ) : (
        <UploaderTrigger>
          Choose File
          <UploaderInput onChange={pick} />
        </UploaderTrigger>
      )}
      <UploaderMessage>Message text</UploaderMessage>
    </Uploader>
  );
}

Usage

import * as React from "react";
import {
  Uploader,
  UploaderDropzone,
  UploaderFileItem,
  UploaderInput,
  UploaderMessage,
} from "@/components/ui/uploader";
import { Label } from "@/components/ui/label";
import { Upload01 } from "@/components/icons/upload-01";

const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`;

/** Live: drop files onto the field, or click it to browse. */
export default function Example() {
  const [files, setFiles] = React.useState<File[]>([]);

  return (
    <Uploader className="w-80">
      <Label htmlFor="upload-default" className="text-xs text-text-muted">
        Label
      </Label>
      <UploaderDropzone
        htmlFor="upload-default"
        text="Drag & Drop or Choose file to upload"
        caption="JPG, GIF or PNG. Max size of 800K"
        onFilesDrop={setFiles}
      >
        <Upload01 />
        <UploaderInput
          id="upload-default"
          multiple
          onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
        />
      </UploaderDropzone>
      {files.map((file) => (
        <UploaderFileItem
          key={file.name}
          name={file.name}
          size={mb(file.size)}
          onRemove={() =>
            setFiles((current) => current.filter((f) => f !== file))
          }
        />
      ))}
      <UploaderMessage>Message text</UploaderMessage>
    </Uploader>
  );
}

Props

Uploader

Takes every prop of UploaderProps.

UploaderDropzone

PropTypeDefault
captionstring | 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>—
extendedboolean—
onFilesDrop(files: File[]) => void—
textstring | 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

PropTypeDefault
actionstring | 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>—
captionstring | 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>—
invalidboolean—
namerequiredstring | 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—
progressnumber—
sizestring | 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>—
thumbnailstring | 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,
};