Skip to content
goodtabs UI
BrowseBrowse components

Upload button

Opens the file picker from a button and shows the chosen file name and size.

npx shadcn@latest add https://ui.goodtabs.co/r/upload-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/upload-button

One file of any type. The name and size appear next to the button with a clear control.

Images only

Favicon

PNG or SVG, square

accept limits the picker, here to PNG and SVG, in a settings row at the sm size.

Multiple with a list

multiple with onFiles feeding a list that shows each file and its size.

Usage

import { UploadButton } from "@/components/ui/upload-button"

<UploadButton accept="image/*" label="Choose image" onFiles={(files) => setAvatar(files[0])} />

Props

PropTypeDefaultDescription
acceptstringAccepted types, as the input's accept attribute, for example "image/*".
multiplebooleanfalseAllow more than one file.
onFiles(files: File[]) => voidCalled with the chosen files, and with an empty array on clear.
labelstring"Choose file"Button label.
clearLabelstring"Clear file"Accessible name of the clear button.
variantButton variant"outline"Any shadcn button variant.
sizeButton size"default"Any shadcn button size.

Source

upload-button/upload-button.tsx
"use client";

import * as React from "react";
import { UploadIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type UploadButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "children" | "type"> & {
  /** Accepted types, as the input's accept attribute, for example "image/*" or ".csv,.json". */
  accept?: string;
  /** Allow more than one file. */
  multiple?: boolean;
  /** Called with the chosen files, and with an empty array on clear. */
  onFiles?: (files: File[]) => void;
  /** Button label. */
  label?: string;
  /** Accessible name of the clear button. */
  clearLabel?: string;
};

/** 1.2 MB, 340 KB, 12 B. */
export function formatFileSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

/**
 * A button that opens the file picker through a hidden input and shows what was chosen.
 * Keeps the files in state; pass onFiles to receive them.
 */
export function UploadButton({
  accept,
  multiple = false,
  onFiles,
  label = "Choose file",
  clearLabel = "Clear file",
  variant = "outline",
  size,
  className,
  disabled,
  ...props
}: UploadButtonProps) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [files, setFiles] = React.useState<File[]>([]);

  const choose = (next: File[]) => {
    setFiles(next);
    onFiles?.(next);
  };

  const clear = () => {
    if (inputRef.current) inputRef.current.value = "";
    choose([]);
  };

  const summary =
    files.length === 0
      ? ""
      : files.length === 1
        ? `${files[0].name}, ${formatFileSize(files[0].size)}`
        : `${files.length} files, ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`;

  return (
    <div data-slot="upload-button" className={cn("inline-flex max-w-full items-center gap-2", className)}>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        disabled={disabled}
        tabIndex={-1}
        aria-hidden="true"
        onChange={(e) => choose(Array.from(e.target.files ?? []))}
        className="sr-only"
      />
      <Button
        type="button"
        variant={variant}
        size={size}
        disabled={disabled}
        onClick={() => inputRef.current?.click()}
        {...props}
      >
        <UploadIcon data-icon="inline-start" aria-hidden="true" />
        {label}
      </Button>
      <span aria-live="polite" className="min-w-0 truncate text-sm text-muted-foreground" title={summary || undefined}>
        {summary}
      </span>
      {files.length > 0 && (
        <Button
          type="button"
          variant="ghost"
          size="icon-sm"
          aria-label={clearLabel}
          disabled={disabled}
          onClick={clear}
          className="shrink-0 text-muted-foreground"
        >
          <XIcon aria-hidden="true" />
        </Button>
      )}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/upload-button/prompt.md, or the Copy AI prompt button above.

When to use

  • Settings rows and forms where one file is attached from a button
  • When a drop zone would be too much and a plain file input too raw
  • Picking several files at once with multiple, then listing them yourself

When not to use

  • Drag and drop uploads with previews and progress: build a drop zone
  • Inside a form that submits the input natively; this one keeps files in state for you to send
  • Camera capture on mobile, which needs the capture attribute on a visible input

Agent notes

  • It takes an onFiles function, so the component that renders it must be a client component ("use client")
  • The file input is visually hidden and out of the tab order; the button opens it and holds focus, the chosen name is announced through aria-live
  • Picking again replaces the files, it does not add; clear calls onFiles with an empty array
  • It extends shadcn's Button, so variant, size and disabled apply to the trigger; formatFileSize is exported for your own lists