Drag & Drop Upload
Dropzone with per-file progress and a drag counter that fixes the flicker every naive dragleave handler produces.
One big file on a connection that will drop: a bar allowed to go backwards when a chunk is retried, a time estimate that is a range or nothing, and a promise about closing the tab made before it is needed.
components/upload-resumable.tsxnpx hoverlab add upload-resumable
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
site-backup-2026-08-27.tar.zst
4.1 GB · 82 chunks · 2 retried
Uploading 1.2 GB of 4.1 GB · time left is not worth guessing at this connection speed
You can close this tab. The upload is stored as up_9f2c41 and carries on from 1.2 GB when you come back — for the next 7 days.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <UploadResumable> — one big file, on a connection that will drop.
*
* File Upload had the dropzone, the progress list, the avatar cropper and
* the CSV mapper. All four assume the upload finishes. This one assumes it
* does not: a 4GB file over hotel wifi, where the interesting states are
* paused, retrying, and "the tab closed an hour ago".
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* The bar is allowed to go backwards. When a chunk fails and is retried,
* the bytes it claimed are given back, and the number drops. Every
* progress bar that only ever increases is lying at exactly the moment the
* user most needs the truth — they watch it reach 98%, sit there, and
* finally fail, having believed for four minutes that it was nearly done.
* A bar that dips by two per cent and says why is trusted afterwards.
*
* TIME REMAINING IS A RANGE, OR IT IS NOTHING
*
* Estimated from a rolling window of recent chunks, not from the average
* since the start, and suppressed entirely while the rate is unstable —
* which is what "3 seconds remaining" for five minutes actually is. A
* range is honest about a number that genuinely is not known.
*
* RESUMING IS PROMISED IN ADVANCE, NOT AFTER THE FACT
*
* Nobody knows that closing the tab is safe unless it says so before they
* do it. The sentence is under the bar the whole time, because the moment
* it is needed is the moment the person has already given up. Its truth
* depends on the upload id living somewhere durable — the comment on
* `uploadId` is where that goes.
*
* CANCEL SAYS WHAT IT THROWS AWAY
*
* "Cancel and delete the 1.2GB already uploaded", not "Cancel". Partial
* uploads cost storage and are billable; a person who meant to pause
* should not discover the difference afterwards.
*
* ACCESSIBILITY: a real `role="progressbar"` with `aria-valuenow` and a
* text `aria-valuetext`, so it is announced as "1.2 of 4.1 gigabytes,
* 29 per cent" rather than a bare number. Status changes go through one
* polite live region rather than each control announcing itself.
*/
import * as React from 'react'
import { CloudUpload, Pause, Play, RotateCcw, Trash2, WifiOff } from 'lucide-react'
export type UploadState = 'uploading' | 'paused' | 'retrying' | 'offline' | 'done'
export interface UploadResumableProps {
fileName?: string
totalBytes?: number
chunkBytes?: number
/**
* The handle a resume needs. Persist it — localStorage, IndexedDB, the
* server's session — or the promise below is not one.
*/
uploadId?: string
className?: string
}
const GB = 1024 ** 3
function size(bytes: number) {
if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`
return `${Math.round(bytes / 1024)} KB`
}
export function UploadResumable({
fileName = 'site-backup-2026-08-27.tar.zst',
totalBytes = 4.1 * GB,
chunkBytes = 0.05 * GB,
uploadId = 'up_9f2c41',
className = '',
}: UploadResumableProps) {
const [sent, setSent] = React.useState(1.18 * GB)
const [state, setState] = React.useState<UploadState>('uploading')
const [retries, setRetries] = React.useState(2)
/* Rolling window of recent chunk rates, in bytes per second. */
const [rates, setRates] = React.useState<number[]>([9.4e6, 11.2e6, 4.1e6, 12.8e6])
React.useEffect(() => {
if (state !== 'uploading') return
const timer = window.setInterval(() => {
setSent((current) => {
const next = current + chunkBytes
if (next >= totalBytes) {
setState('done')
return totalBytes
}
return next
})
setRates((r) => [...r.slice(-5), 6e6 + Math.round(Math.sin(Date.now() / 900) * 4e6 + 5e6)])
}, 900)
return () => window.clearInterval(timer)
}, [state, chunkBytes, totalBytes])
const pct = Math.min(100, (sent / totalBytes) * 100)
const remainingBytes = Math.max(0, totalBytes - sent)
/* Recent chunks only. An average since the start is a different lie. */
const recent = rates.slice(-4)
const mean = recent.reduce((a, b) => a + b, 0) / Math.max(1, recent.length)
const spread = Math.max(...recent) / Math.max(1, Math.min(...recent))
const stable = recent.length >= 3 && spread < 2.5
const lowSeconds = remainingBytes / Math.max(...recent)
const highSeconds = remainingBytes / Math.max(1, Math.min(...recent))
const minutes = (s: number) => Math.max(1, Math.round(s / 60))
const eta =
state !== 'uploading'
? null
: stable
? `about ${minutes(lowSeconds)}–${minutes(highSeconds)} minutes left`
: /* Suppressed rather than invented. */
'time left is not worth guessing at this connection speed'
const retryChunk = () => {
setState('retrying')
/* The bar gives the failed chunk's bytes back. It is allowed to. */
setSent((current) => Math.max(0, current - chunkBytes))
setRetries((r) => r + 1)
window.setTimeout(() => setState('uploading'), 1200)
}
const STATUS: Record<UploadState, string> = {
uploading: `Uploading ${size(sent)} of ${size(totalBytes)}`,
paused: 'Paused. Nothing already uploaded has been lost.',
retrying: `Chunk failed — sending it again. Progress went back by ${size(chunkBytes)}.`,
offline: 'Connection lost. It will pick up from here when you are back.',
done: 'Upload complete.',
}
return (
<section className={`mx-auto w-full max-w-xl px-4 py-16 sm:px-6 ${className}`}>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex items-start gap-3">
<span
aria-hidden
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground"
>
{state === 'offline' ? (
<WifiOff className="h-5 w-5" />
) : (
<CloudUpload className="h-5 w-5" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{fileName}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{size(totalBytes)} · {Math.ceil(totalBytes / chunkBytes)} chunks
{retries > 0 ? ` · ${retries} retried` : ''}
</p>
</div>
<span className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
{Math.floor(pct)}%
</span>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.floor(pct)}
/* Announced in the unit that means something, not a bare number. */
aria-valuetext={`${size(sent)} of ${size(totalBytes)}, ${Math.floor(pct)} per cent`}
className="mt-4 h-2 w-full overflow-hidden rounded-full border border-transparent bg-muted"
>
<div
className={`h-full rounded-full border border-transparent transition-[width] duration-500 ${
state === 'retrying' ? 'bg-amber-500' : 'bg-primary'
}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* One live region for the whole component. */}
<p aria-live="polite" className="mt-2 min-h-5 text-xs text-muted-foreground">
{STATUS[state]}
{eta ? ` · ${eta}` : ''}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{state === 'done' ? null : state === 'paused' || state === 'offline' ? (
<button
type="button"
onClick={() => setState('uploading')}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Play aria-hidden className="h-4 w-4" />
Resume from {size(sent)}
</button>
) : (
<button
type="button"
onClick={() => setState('paused')}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Pause aria-hidden className="h-4 w-4" />
Pause
</button>
)}
<button
type="button"
onClick={retryChunk}
disabled={state === 'done'}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<RotateCcw aria-hidden className="h-4 w-4" />
Simulate a failed chunk
</button>
{/* Names what it throws away. */}
<button
type="button"
className="ms-auto inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive transition hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Trash2 aria-hidden className="h-4 w-4" />
Cancel and delete the {size(sent)} already uploaded
</button>
</div>
{/* Said before it is needed, not after. */}
<p className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
You can close this tab. The upload is stored as{' '}
<code className="rounded bg-muted px-1 font-mono">{uploadId}</code> and
carries on from {size(sent)} when you come back — for the next 7 days.
</p>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/upload-resumable.tsx and import it where you need the section:
import { UploadResumable } from '@/components/upload-resumable'2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
fileName | string | 'site-backup-2026-08-27.tar.zst' |
totalBytes | number | 4.1 * GB |
chunkBytes | number | 0.05 * GB |
uploadIdThe handle a resume needs. Persist it — localStorage, IndexedDB, the server's session — or the promise below is not one. | string | 'up_9f2c41' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Resumable Large Upload — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-xl px-4 py-16 sm:px-6 ">
<div class="rounded-2xl border border-border bg-card p-5">
<div class="flex items-start gap-3">
<span aria-hidden="true" class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-upload h-5 w-5" aria-hidden="true">
<path d="M12 13v8"></path>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="m8 17 4-4 4 4"></path>
</svg>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">site-backup-2026-08-27.tar.zst</p>
<p class="mt-0.5 text-xs text-muted-foreground">4.1 GB · 82 chunks · 2 retried</p>
</div>
<span class="shrink-0 text-sm font-semibold tabular-nums text-foreground">28%</span>
</div>
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="28" aria-valuetext="1.2 GB of 4.1 GB, 28 per cent" class="mt-4 h-2 w-full overflow-hidden rounded-full border border-transparent bg-muted">
<div class="h-full rounded-full border border-transparent transition-[width] duration-500 bg-primary" style="width:28.780487804878053%"></div>
</div>
<p aria-live="polite" class="mt-2 min-h-5 text-xs text-muted-foreground">Uploading 1.2 GB of 4.1 GB · time left is not worth guessing at this connection speed</p>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pause h-4 w-4" aria-hidden="true"><rect x="14" y="4" width="4" height="16" rx="1"></rect><rect x="6" y="4" width="4" height="16" rx="1"></rect></svg>Pause</button>
<button type="button" class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw h-4 w-4" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Simulate a failed chunk</button>
<button type="button" class="ms-auto inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive transition hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 lucide-trash-2 h-4 w-4" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg>Cancel and delete the 1.2 GB already uploaded</button>
</div>
<p class="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">You can close this tab. The upload is stored as <code class="rounded bg-muted px-1 font-mono">up_9f2c41</code> and carries on from 1.2 GB when you come back — for the next 7 days.</p>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse File UploadCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Dropzone with per-file progress and a drag counter that fixes the flicker every naive dragleave handler produces.
The list that follows the dropzone: complete, uploading, queued and failed rows with cancel and retry — because hiding the failure is the classic upload bug.
The screen between "file uploaded" and "data imported": source columns mapped to schema fields, with a sample value under each and required fields blocking the button.
Framing a profile photo with keyboard-operable zoom and position, showing what gets trimmed rather than hiding it behind the mask.
An upload form that states the constraints before the file is chosen, rather than rejecting it after a two-minute transfer.
The import path for a file that is already on the internet, which does not need to travel through the browser at all.