21 hand-coded CSS file upload buttons and dropzones for SaaS document flows, e-commerce media, CMS libraries, and profile settings. Covers the custom-label button that replaces the unstylable native control, drag-and-drop zones with grid previews, avatar and image previews, real progress bars, chunked uploads, retry states, and Tailwind and Bootstrap variants. The native input stays keyboard-reachable rather than hidden from assistive tech. Scoped under .fu-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
You just browsed 21 uploads. Nucleo is a library of 44,000+ SVG icons in five families (Core, UI, Sharp, Pixel and Micro Bold), with web, Mac and Windows apps to manage them. The All Icons Bundle is $149 with lifetime updates.
The one pattern every other demo in this collection is built on: a visually hidden but still focusable<input type="file"> paired with a <label> you can style like any button. The browser's native picker still opens, the control still lands in the tab order, and screen readers still announce it as a file input — because the input is clipped, not display:none. Hover spins a conic halo through a registered @property angle; focus projects onto the label with an adjacent-sibling selector.
Most "pure CSS" upload buttons stop at styling the label — they cannot tell you whether a file was actually picked. This one can. A required file input is :invalid while empty and flips to :valid the moment the user chooses something, so :has(input:required:valid) swaps the entire card into its attached state — checkmark, colour, copy and a @starting-style pop — with zero JavaScript.
The most-searched follow-up to a custom upload button: how do I show what the user picked? Ten lines of JavaScript read input.files[0], format the byte count with Intl.NumberFormat, derive an extension chip, and write the result into a row that animates in. Long names truncate from the middle with a two-span technique instead of a lossy ellipsis, and the change is announced once through a polite live region.
A real multi-file queue, including the part almost every tutorial skips: removing a single file. A FileList is read-only, so the only supported way to drop one entry is to rebuild the list through a DataTransfer object and assign it back to input.files. Rows are cloned from a <template>, the running total is formatted with Intl.NumberFormat, and the whole list is a real <ul> so screen readers announce "list, 3 items".
The reference implementation for the whole collection: a file input that passes a keyboard-only audit, a screen-reader pass and a forced-colors check. Help text is wired with aria-describedby, validation failures set aria-invalid and point at a role="alert" message via aria-errormessage, focus is moved deliberately after every state change, and the focus indicator meets WCAG 2.2's Focus Appearance criterion — 2px thick, 3:1 contrast, never clipped.
The canonical dropzone, done to spec. All four drag events call preventDefault() (miss one and the browser navigates away to the dropped file), dragleave flicker from child elements is fixed with a depth counter, and the zone is a real role="button" with tabindex="0" that opens the picker on Space or Enter. The dashed outline is an SVG stroke-dasharray that marches only while a drag is over it.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
Drop a folder of photos and watch them land in a responsive gallery. Thumbnails come from URL.createObjectURL(file) — no FileReader, no base64, no memory bloat — and every URL is revoked on removal so long sessions do not leak. The grid is one repeat(auto-fill, minmax()) line, so it reflows from a phone to an ultrawide with no media queries and no breakpoints to maintain.
When upload order is the output — carousels, PDF page order, photo albums — the queue has to be sortable. This list is reorderable by pointer drag and by keyboard: with a row focused, <kbd>Alt</kbd> + <kbd>↑</kbd>/<kbd>↓</kbd> moves it and the new position is announced. Position numbers come from a CSS counter, so nothing has to be renumbered in JavaScript.
The pattern every modern file app uses: you can drop anywhere. Listeners live on window, so the overlay appears the instant a file crosses the viewport edge and disappears when the drag leaves — detected reliably with a depth counter rather than the notoriously unreliable relatedTarget. The overlay is a <dialog>-free layer with backdrop-filter, inert content behind it, and both hidden and display:none so no stylesheet can accidentally reveal it.
One image, one card, and everything you actually want to know before uploading: the thumbnail, the pixel dimensions read from naturalWidth/naturalHeight, the file weight, and a transparency-revealing checkerboard behind it so a PNG with an alpha channel is obvious at a glance. Replace and remove are separate affordances, because "choose a different one" and "clear this field" are different intents.
A profile photo control that behaves like the ones in real products: hover (or focus) reveals an edit scrim over a circular preview, a live zoom slider drives scale() on the image so users can frame their face, and a conic ring sweeps once when a new photo lands. The overlay is a <label>, so the entire circle is a legitimate click target and a keyboard-focusable control.
The listing-photo uploader: a grid where the add tile lives inside the grid, remove badges are always visible (not hover-only, because hover does not exist on touch), and the first tile is automatically the cover — labelled, not implied. Removing a tile re-flows the grid and re-announces the new cover, which is the detail that makes a photo uploader feel considered.
Upload a video and choose its poster frame — the way every serious video product does it. The <video> element is seeked to evenly spaced timestamps and each frame is painted into a <canvas> with drawImage, producing a real filmstrip from the user's own file. Duration is formatted with Intl.NumberFormat, and the selected frame is a radio group, so arrow keys move between frames natively.
A determinate progress bar that reports like a real transfer: percentage, bytes moved, throughput and a live ETA, all driven from one registered @property so the fill, the sheen and the numeric readout stay in lockstep. It is a proper role="progressbar" with aria-valuenow, and it announces at milestones rather than on every frame — the difference between helpful and unbearable with a screen reader.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
What a resumable, chunked uploader actually looks like: each file is a row of discrete chunk segments that fill one by one, one chunk fails on purpose and turns red with a retry, and an aggregate bar sums the whole queue. Chunk state lives in data-* attributes so all the visual logic is CSS attribute selectors — the script only ever flips a value.
Five states — idle, uploading, success, error, retrying — and one attribute. Every visual difference between them is a CSS [data-state] selector, so the script's entire job is card.dataset.state = 'error'. That is the discipline that keeps upload UIs from rotting: no orphan classes, no impossible combinations like "success and error at once", and a state you can read straight out of the DOM in DevTools.
Uploading rarely means "from this laptop" any more. This zone offers three sources — device, connected cloud drive, and a direct URL — switched entirely in CSS with a radio group and :has(), so tabbing between sources costs zero JavaScript. A quota meter, a destination bucket selector and per-source empty states make it read like a real storage product rather than a styled input.
On a phone, <input type="file" accept="image/*" capture="environment"> opens the rear camera directly, skipping the picker entirely. This is the two-door pattern that gets it right: a primary camera trigger, a secondary library trigger, both real file inputs, both with 56px targets, and a layout that respects env(safe-area-inset-bottom) so the buttons clear the home indicator.
Screenshots never touch the filesystem, so the fastest upload is <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>V</kbd>. This area listens for paste and reads e.clipboardData.files — plus a click-to-read fallback using the async navigator.clipboard.read() API for users who prefer a button. The target is a real focusable region with a keyboard hint, so it is discoverable rather than a hidden trick.
A dropzone written the way you would write it in a Tailwind project — utility classes on every element, sr-only hiding the input, peer-focus-visible projecting focus onto the label — but with no Tailwind runtime required. The same style block ships a scoped vanilla implementation of every utility used, so it renders identically in a plain HTML page and you can delete that block the moment you paste it into a real Tailwind build.
Bootstrap 5 dropped the old .custom-file wrapper: a file input is now just .form-control, and its native button is styled through the ::file-selector-button pseudo-element. This demo shows both routes side by side — the native styled input (best accessibility, zero JS) and the classic .btn-label pattern with a visually hidden input — plus validation feedback and a progress bar, all as scoped CSS with no Bootstrap CDN dependency.
FAQ
Frequently asked questions
How do I style a file input when the native control cannot be styled?
The native <input type="file"> renders a browser-controlled button that CSS cannot reach — no pseudo-element gives you the button text, and its appearance differs across Chrome, Safari and Firefox. The universal pattern is to pair the input with a <label>: <input type="file" id="upload" class="visually-hidden"><label for="upload" class="btn">Choose a file</label>. Clicking a label activates its associated input for free, so you style the label as any button and the native control never shows. The critical detail is how you hide the input. display: none and visibility: hidden both remove it from the accessibility tree and from the tab order, so keyboard users can no longer reach the control at all — a real and common accessibility failure. Use the visually-hidden clip pattern instead: position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;. The input stays focusable and announced, and you get a fully styled trigger. Then style .btn:has(+ input:focus-visible) or use :focus-within so the visible button shows a focus ring when the hidden input is focused.
How do I show the selected file name after a user picks a file?
The native control shows "No file chosen" until a selection is made, and once you hide it for styling you lose that feedback entirely — so you have to provide it. Listen for change on the input and read input.files, which is a FileList: const name = input.files[0]?.name ?? 'No file chosen'. For multiple files, input.files.length gives the count, and the friendly form is usually a count plus the first name rather than a wall of filenames. Two details make this production-grade. Format the size with a helper rather than showing raw bytes — (bytes / 1024 / 1024).toFixed(1) + ' MB' is what people expect. And put the filename in a live region with aria-live="polite" so screen reader users hear the selection confirmed; without it, the interaction completes silently for them. Long filenames are the other trap: a 90-character name will blow out a flex container, so pair min-width: 0 on the flex child with text-overflow: ellipsis, or middle-truncate so the extension stays visible.
How do I build a drag-and-drop upload zone correctly?
Four events, and the two people forget are the ones that matter. dragover must call preventDefault() or the browser opens the file instead of letting you handle it — this is the single most common reason a dropzone "does nothing". dragenter and dragleave drive the visual state, but dragleave fires when the pointer crosses onto a child element too, so a naive implementation flickers constantly. Fix it with a counter that increments on enter and decrements on leave, showing the active state only while the count is above zero. drop also needs preventDefault(), then reads e.dataTransfer.files. Two production requirements beyond the mechanics. Drag and drop must never be the only way to upload — WCAG 2.2's Dragging Movements criterion requires a single-pointer alternative, so the zone should always contain a real clickable button that opens the file picker. And validate on drop: users can drop folders, 4GB videos or executables, so check type and size before doing anything, and tell them clearly when something is rejected.
How do I preview an image before it uploads?
Two approaches, and one is meaningfully better than the other. URL.createObjectURL(file) returns a blob URL synchronously that you can assign straight to img.src — it is fast, uses almost no memory, and is the right default. The catch is that it holds a reference until you release it, so call URL.revokeObjectURL(url) once the image has loaded or when the preview is removed; forgetting to is a genuine memory leak in a long-lived page where users upload repeatedly. The alternative, FileReader.readAsDataURL(), is asynchronous and produces a base64 string roughly 33% larger than the file, which for a 5MB photo means holding about 6.7MB of string in memory. Use it only when you actually need the data URL, such as embedding in a payload. For the layout, lock the preview with aspect-ratio and object-fit: cover so a portrait photo and a landscape one occupy identical space — otherwise the grid reflows as each preview loads, which is both ugly and a cumulative layout shift penalty. Always set the alt to the filename so the preview is not silent to screen readers.
How do I show real upload progress rather than a fake animation?
Real progress requires XMLHttpRequest or a streaming fetch, and this surprises people: plain fetch() gives you no upload progress events at all. With XHR it is xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) setPercent(e.loaded / e.total * 100) }). Note xhr.upload, not xhr — listening on the request itself reports download progress and will look stuck at zero. Drive the bar from a single custom property so the visual and the accessible value can never disagree: write --pct, scale the fill with transform: scaleX(var(--pct)) rather than animating width, and mirror the number into aria-valuenow on a role="progressbar". For large files, chunked upload is the honest pattern — slice with file.slice(start, end), upload sequentially, and compute overall progress as completed chunks plus the current chunk's fraction, which also gives you resumability. A determinate bar that jumps to 90% and waits is worse than an indeterminate spinner, because it promises information it does not have.
What are the accessibility requirements for a file upload?
Five things, and the first is the one most custom uploaders get wrong. The native input must remain focusable — hiding it with display: none removes it from the tab order, so a keyboard user can never trigger the picker no matter how good your button looks. Use the visually-hidden clip pattern. Second, the visible trigger needs a real label association, either a <label for> or an aria-labelledby pointing at your button text. Third, focus must be visible on the styled element even though the focused element is the hidden input — :focus-within on the wrapper, or :has(+ input:focus-visible), both work. Fourth, every state change needs announcing through a polite live region: file selected, upload started, upload complete, upload failed. Silence after an action is the most common screen reader complaint about upload widgets. Fifth, per WCAG 2.2, drag and drop needs a single-pointer alternative, and any drag-to-reorder list needs keyboard equivalents — arrow keys to move an item satisfy both that and the general keyboard-operable requirement.
How should I validate file type and size on the client?
Client-side validation is for user experience only — it tells someone immediately that a 40MB file is too large instead of making them wait for a rejected upload. It is never a security control, because anything sent from a browser can be forged, so the server must repeat every check regardless. Set accept="image/png,image/jpeg" or accept="image/*" on the input to filter the OS picker, but treat it as a hint: it does not block drag-and-drop, and users can override it in the dialog. So validate in JavaScript too, checking file.type against an allowlist and file.size against your limit. Two refinements worth the effort. Do not trust the extension or the reported MIME type for anything sensitive — read the first few bytes and check the magic number if the file matters. And write error messages that state the limit and the actual value: "That file is 42MB. The maximum is 10MB" is actionable, whereas "Invalid file" sends people to support. Show the constraint before the user picks, not only after they fail.
How do these uploads port to React, Vue, Svelte and Next.js?
The CSS ports unchanged; the JavaScript needs one structural change, because file state belongs in your component rather than in the DOM. In React the input is typically uncontrolled — you cannot set value on a file input for security reasons — so use a useRef for the element and useState for the selected File objects, rendering previews from state. Create object URLs inside a useEffect and revoke them in its cleanup, or you leak on every re-render. Vue uses ref plus onBeforeUnmount, Svelte onMount returning cleanup. All of these need 'use client' in the Next.js App Router since they touch FileList and drag events, and for the upload itself a Server Action or route handler receiving FormData is the current idiom. Two porting notes specific to uploads: clearing a file input requires setting input.value = '' directly, which React state cannot do for you; and the drag counter described above must live in a ref rather than state, since updating state on every dragenter causes a re-render storm.