21 CSS File Upload Buttons

2 Pure CSS19 Light JSPublished

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.

Related28 CSS Input Fields16 CSS Multi-Step Form UI22 CSS Progress Bars51 CSS Buttons

Premium icon pickPartner: Nucleo

Affiliate link

Need icons for the components you just copied?

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.

Get the Nucleo bundle

Nucleo. We may earn a commission, at no extra cost to you.

01. File Upload Button with Custom Label (opens in a new tab)

Published

Pure CSS
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.

02. Pure CSS File Upload Button (opens in a new tab)

Published

Pure CSS
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.

03. File Upload with File Name Display (opens in a new tab)

Published

Light JS
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.

04. Multi-File Upload with Selected List (opens in a new tab)

Published

Light JS
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".

05. Accessible Custom File Input (opens in a new tab)

Published

Light JS
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.

06. Drag and Drop File Upload Zone (opens in a new tab)

Published

Light JS
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.

at

Almost there: check your inbox to confirm.Manage

07. Drag and Drop Upload Grid Preview (opens in a new tab)

Published

Light JS
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.

08. Reorderable Drag and Drop Upload List (opens in a new tab)

Published

Light JS
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.

09. Full-Page Drag and Drop Overlay (opens in a new tab)

Published

Light JS
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.

10. Image Upload with Preview Thumbnail (opens in a new tab)

Published

Light JS
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.

11. Avatar Upload with Circular Preview (opens in a new tab)

Published

Light JS
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.

12. Multi-Image Upload Grid (opens in a new tab)

Published

Light JS
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.

13. Video Upload with Frame Preview (opens in a new tab)

Published

Light JS
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.

14. File Upload Progress Bar (opens in a new tab)

Published

Light JS
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.

at

Almost there: check your inbox to confirm.Manage

15. Chunked Upload Progress List (opens in a new tab)

Published

Light JS
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.

16. Upload Success Error Retry States (opens in a new tab)

Published

Light JS
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.

17. Cloud Storage Drop Zone (opens in a new tab)

Published

Light JS
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.

18. Mobile Camera Capture Upload (opens in a new tab)

Published

Light JS
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.

19. Clipboard Image Paste Upload (opens in a new tab)

Published

Light JS
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.

20. Tailwind File Upload Dropzone (opens in a new tab)

Published

Light JS
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.

21. Bootstrap 5 Custom File Input (opens in a new tab)

Published

Light JS
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.

Related collections

15 CSS 3D Tilt Hover Cards preview

15 CSS 3D Tilt Hover Cards

15 mouse-aware 3D tilt hover cards — e-commerce product spotlight, glassmorphism parallax team card, interactive pricing tier, holographic NFT collectible, pop-out mascot, dark tech grid with border glow, media player album art, blog article preview, cyberpunk neon glow, dashboard KPI widget, minimalist real estate, flip-to-back tilt, mobile app showcase, course learning card, and a Pure CSS touch-friendly tilt. Vanilla JS writes --rx/--ry/--mx/--my; all rendering stays in CSS on the GPU.

24 CSS Animated Cards preview

24 CSS Animated Cards

24 hand-coded CSS animated card patterns organised by what triggers the motion, not by card style. Scroll and entrance triggers: staggered grid reveals, @starting-style mount-in, scroll-driven scale and fade on animation-timeline: view(), View Transitions API card-to-detail morphs, blur-to-sharp lazy image loading, and FLIP re-layout when a filter changes. State-change triggers: add-to-cart success morph, like and save particle burst, expand and collapse to intrinsic height with calc-size(), swipe-to-dismiss on Pointer Events, and skeleton-to-loaded crossfade. Data-driven triggers: count-up KPI metrics, SVG sparkline draw-on-enter, and progress ring fill on load. Ambient idle loops: breathing glow, floating drift, animated gradient mesh backgrounds, conic rotating borders, auto-cycling testimonial decks, and live status pulses. Plus four hover patterns where the effect is the topic itself: cursor spotlight, holographic foil glare, corner ribbon slide, and horizontal accordion expand. Every card is scoped under a .ac-NN prefix for no-collision pasting, guards prefers-reduced-motion, animates compositor-only properties, and ports unchanged to React, Vue, Svelte, Astro, Next.js and Tailwind.

22 CSS Avatars preview

22 CSS Avatars

22 hand-coded CSS avatars for chat apps, team dashboards, comment threads, account menus, and social profiles. Covers circular and squircle shapes, gradient and conic story rings, hexagon clip-path crops, online status dots, notification count badges, verified checkmarks, stacked facepiles with plus-N overflow, initials fallbacks for users without photos, broken-image recovery, avatar pickers, and a size scale system where the ring, badge, and status dot all scale from a single custom property.

Search CodeFronts

Loading…