21 CSS File Upload Buttons

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.

2 pure CSS19 light JSPublished

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

File Upload Button with Custom Label — preview
01 / 21Pure CSS

File Upload Button with Custom Label

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.

Published

Pure CSS File Upload Button — preview
02 / 21Pure CSS

Pure CSS File Upload Button

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.

Published

File Upload with File Name Display — preview
03 / 21Light JS

File Upload with File Name Display

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.

Published

Multi-File Upload with Selected List — preview
04 / 21Light JS

Multi-File Upload with Selected List

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

Published

Accessible Custom File Input — preview
05 / 21Light JS

Accessible Custom File Input

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.

Published

Drag and Drop File Upload Zone — preview
06 / 21Light JS

Drag and Drop File Upload Zone

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.

Published

Drag and Drop Upload Grid Preview — preview
07 / 21Light JS

Drag and Drop Upload Grid Preview

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.

Published

Reorderable Drag and Drop Upload List — preview
08 / 21Light JS

Reorderable Drag and Drop Upload List

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.

Published

Full-Page Drag and Drop Overlay — preview
09 / 21Light JS

Full-Page Drag and Drop Overlay

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 &lt;dialog&gt;-free layer with backdrop-filter, inert content behind it, and both hidden and display:none so no stylesheet can accidentally reveal it.

Published

Image Upload with Preview Thumbnail — preview
10 / 21Light JS

Image Upload with Preview Thumbnail

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.

Published

Avatar Upload with Circular Preview — preview
11 / 21Light JS

Avatar Upload with Circular Preview

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 &lt;label&gt;, so the entire circle is a legitimate click target and a keyboard-focusable control.

Published

Multi-Image Upload Grid — preview
12 / 21Light JS

Multi-Image Upload Grid

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.

Published

Video Upload with Frame Preview — preview
13 / 21Light JS

Video Upload with Frame Preview

Upload a video and choose its poster frame — the way every serious video product does it. The &lt;video&gt; element is seeked to evenly spaced timestamps and each frame is painted into a &lt;canvas&gt; 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.

Published

File Upload Progress Bar — preview
14 / 21Light JS

File Upload Progress Bar

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.

Published

Chunked Upload Progress List — preview
15 / 21Light JS

Chunked Upload Progress List

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.

Published

Upload Success Error Retry States — preview
16 / 21Light JS

Upload Success Error Retry States

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.

Published

Cloud Storage Drop Zone — preview
17 / 21Light JS

Cloud Storage Drop Zone

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.

Published

Mobile Camera Capture Upload — preview
18 / 21Light JS

Mobile Camera Capture Upload

On a phone, &lt;input type="file" accept="image/*" capture="environment"&gt; 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.

Published

Clipboard Image Paste Upload — preview
19 / 21Light JS

Clipboard Image Paste Upload

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.

Published

Tailwind File Upload Dropzone — preview
20 / 21Light JS

Tailwind File Upload Dropzone

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.

Published

Bootstrap 5 Custom File Input — preview
21 / 21Light JS

Bootstrap 5 Custom File Input

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.

Published

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…