If you search for a Shadcn UI image upload component, you’ll quickly notice there isn’t one. That’s intentional.
You might try running npx shadcn add upload, only to find that no upload component exists. Then you end up on the same GitHub discussions as many other developers asking the same question: Where is the image upload component?
The honest answer is: you build it yourself.
At first, that might seem surprising, but it actually makes sense. Shadcn UI isn’t a library where you install ready-made components. Instead, it gives you building blocks that you add to your own codebase and customise as needed.
Image upload is different for every application. The upload flow, states, and backend integration can vary a lot, so a single upload component wouldn’t work for every use case.
Shadcn UI ships no built-in image upload component; you compose one from its primitives (Button, Card, Progress, Dialog) around an upload engine that handles files, previews, and errors. The clean split is shadcn for presentation and a dedicated uploader for transfer. Filestack’s React SDK slots in as that engine, keeping the shadcn look while adding chunked, resumable uploads.
In this guide, we’ll build an image upload component step by step. We’ll use Shadcn UI to create the interface, add state management and validation, and then connect it to an upload service.
The best part is that you can change the upload provider later without rebuilding the UI. The interface stays the same while the upload logic can be swapped whenever you need.
Key Takeaways
- Shadcn/ui has no upload component on purpose. You compose one from Card, Button, Progress, and Dialog.
- A working upload UI needs four pieces: a dropzone surface, file rows, a progress bar per row, and error text inside the row, not a toast.
- A hidden
<input type="file">paired with a<label>keeps the dropzone accessible and keyboard operable. - Keep transfer logic behind a small interface. Swapping
fetch()for a real upload SDK should change zero markup. - Filestack’s React SDK maps its progress callbacks directly onto Shadcn’s
<code>Progresscomponent, so the engine and the UI stay decoupled.
Before building anything, it’s worth understanding why this gap exists, because it shapes every decision after it.
Why Shadcn Does Not Ship an Uploader
Shadcn’s whole philosophy is “copy the code, own the code.” That works well for a button or a dialog, because those components don’t hold much internal state. An uploader is different. It has to track file selection, per-file progress, retries, validation errors, and network failures, all at once. Trying to template that into one drop-in component would mean baking in assumptions about your backend, your file size limits, and your error handling, exactly the kind of lock-in shadcn tries to avoid.
So instead of asking “what are the best React components for file uploading,” the more useful question becomes “which primitives do I already have, and what’s missing?” As it turns out, you already have most of what you need. What’s missing is the part that actually talks to a server.
With that context in place, let’s start stacking primitives into an actual dropzone.
Composing the Surface: Dropzone plus Rows plus Progress
This is where shadcn earns its keep. A React drag-and-drop file upload surface and a React file upload component for the file list are really the same composition problem, just two different views of it.
The dropzone itself is a Card wrapping a hidden file input and a label. The label pattern matters here: clicking anywhere on the label opens the file picker, and because it’s a real form control under the hood, keyboard users can tab to it and hit Enter or Space to open it too. Below the dropzone, each selected file becomes its own row, and each row gets its own Progress bar.
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
export function ImageUploader({ files, onFilesSelected, onRetry }) {
return (
<Card className="p-6">
<label
htmlFor="file-input"
className={cn(
"flex flex-col items-center justify-center gap-3",
"rounded-lg border-2 border-dashed border-muted-foreground/30",
"py-10 text-center cursor-pointer hover:border-primary/50"
)}
>
<span className="font-medium">Drag and drop images here</span>
<span className="text-sm text-muted-foreground">or click to browse</span>
<input
id="file-input"
type="file"
multiple
accept="image/*"
className="sr-only"
onChange={(e) => onFilesSelected(Array.from(e.target.files))}
/>
</label>
<ul className="mt-6 space-y-3">
{files.map((file) => (
<li key={file.id} className="rounded-md border p-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{file.name}</span>
{file.status === "failed" ? (
<button
onClick={() => onRetry(file.id)}
className="text-destructive underline"
>
Retry
</button>
) : (
<span className="text-muted-foreground">{file.status}</span>
)}
</div>
<Progress value={file.progress} className="mt-2 h-2" />
{file.error && (
<p className="mt-1 text-xs text-destructive">{file.error}</p>
)}
</li>
))}
</ul>
</Card>
);
}

Notice the error text sits inside the row, right under that file’s own progress bar, instead of floating away in a toast. A toast disappears in a few seconds. A row stays put until the user deals with it, which matters when three out of twenty files failed, and you don’t want the person hunting for which ones.
The surface is only half the job, though. Right now, none of this actually tracks state. Let’s fix that next.
State and Validation
With the JSX in place, the component needs somewhere to keep track of what’s happening to each file, and a way to say no to files that shouldn’t be there in the first place.
A useReducer keyed by file ID works well here, since every file’s status changes independently of the others. Validation, checking file type and size, happens the moment a file is selected, before any request goes out. This answers a common early question too: file uploading in React JS almost always starts with this same shape, a reducer plus a validation step, no matter which transport ends up sending the bytes.
function uploadReducer(state, action) {
switch (action.type) {
case "ADD_FILES":
return {
...state,
...Object.fromEntries(
action.files.map((f) => [f.id, { ...f, status: "queued", progress: 0 }])
),
};
case "PROGRESS":
return {
...state,
[action.id]: { ...state[action.id], status: "uploading", progress: action.pct },
};
case "DONE":
return { ...state, [action.id]: { ...state[action.id], status: "done", progress: 100 } };
case "ERROR":
return {
...state,
[action.id]: { ...state[action.id], status: "failed", error: action.message },
};
default:
return state;
}
}
Validation lives right where files enter the component:
function validateFile(file) {
if (!file.type.startsWith("image/")) return "Only image files are allowed.";
if (file.size > 10 * 1024 * 1024) return "File is larger than 10MB.";
return null;
}
Most guides on implementing image uploads in React stop here, with a working component and simulated progress. That’s enough to show how the UI works.
But in a real application, you also need something that uploads the file and reports the actual upload progress. That’s where choosing the right upload solution becomes important.
The Engine Swap: fetch to SDK
This is the piece that makes the whole composition worth the extra setup: the transport layer sits behind a small interface, so the shadcn component above never needs to know or care how bytes actually get to the server.
Start with the interface itself. It only needs one method, and it only needs to report progress and completion:
// uploadEngine.js
export function createFetchEngine(endpoint) {
return {
upload(file, { onProgress, onDone, onError }) {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append("file", file);
xhr.upload.onprogress = (e) => {
onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => (xhr.status < 300 ? onDone(xhr.response) : onError("Upload failed"));
xhr.onerror = () => onError("Network error");
xhr.open("POST", endpoint);
xhr.send(form);
},
};
}
A React JS file upload component built this way already works. The catch is that raw fetch or XMLHttpRequest gives you one request per file, no chunking, and no way to resume a large upload that drops halfway through. Swapping in Filestack’s React SDK as the engine keeps the exact same interface, but the internals now handle chunked, resumable transfer:
import { init } from "filestack-js";
export function createFilestackEngine(apiKey) {
const client = init(apiKey);
return {
upload(file, { onProgress, onDone, onError }) {
client
.upload(file, {
onProgress: (evt) => onProgress(Math.round(evt.totalPercent)),
})
.then((res) => onDone(res))
.catch((err) => onError(err.message));
},
};
}

Both upload engines use the same upload(file, callbacks) function, so you don’t need to change the component from Section 2. The only thing that changes is the upload engine you pass into it.
The Managed Route: Shadcn Look, Production Engine
Building your own fetch-based engine is a fine way to learn the shape of the problem, and it’s genuinely enough for a small internal tool. But once you need chunking for large images, resumable uploads on flaky connections, or reliable retry behaviour, that engine starts asking for real maintenance time.
Keep the markup, upgrade the engine: wire the composed component to a production upload ui and the same shadcn rows gain chunked transfer, retries, and 5GB file support, without a redesign. The onProgress callback from the React SDK maps one-to-one onto the Progress value you’re already rendering, so the swap really is as small as it looks in the code above.
This is also where teams building something like a profile picture uploader tend to land. The UI stays identical to a plain gallery upload, same dropzone, same rows, but the failure modes that matter for a single, important image (say, someone’s profile photo) get handled by the engine instead of a hand-rolled retry loop.
If you’re curious how that plays out for single-file, high-stakes uploads, our React file upload walkthrough covers that shape in more depth.
Conclusion: Own the Pixels, Outsource the Packets
The lesson underneath all of this is simpler than it looks in the code: shadcn was never going to ship an uploader, because uploading isn’t really a presentation problem. It’s a transport problem wearing a UI.
Split the two apart, and both sides get easier. You keep full control over how the dropzone and rows look and feel, since that’s just your own JSX and Tailwind classes. And you keep the option to swap the engine underneath, from a quick fetch call to something built for chunking and retries, without ever touching that markup again.
If you want to see it end to end, copy the composed component above and connect it to Filestack to see real progress values fill in those same progress bars.
FAQ
Does shadcn/ui include an image upload component?
No. You compose one from its primitives around an upload engine that handles the actual file transfer.
Which shadcn primitives does the composition use?
Card, Button, Progress, and Dialog, plus a hidden file input paired with a label for accessibility.
Can the composed component do resumable uploads?
Yes, as long as the engine layer supports chunking. Filestack’s React SDK does this without changing any of the shadcn markup above it.
Shefali Jangid is a web developer, technical writer, and content creator with a love for building intuitive tools and resources for developers.
She writes about web development, shares practical coding tips on her blog shefali.dev, and creates projects that make developers’ lives easier.
Read More →