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
| Prop | Type | Default | Description |
|---|---|---|---|
| accept | string | Accepted types, as the input's accept attribute, for example "image/*". | |
| multiple | boolean | false | Allow more than one file. |
| onFiles | (files: File[]) => void | Called with the chosen files, and with an empty array on clear. | |
| label | string | "Choose file" | Button label. |
| clearLabel | string | "Clear file" | Accessible name of the clear button. |
| variant | Button variant | "outline" | Any shadcn button variant. |
| size | Button 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