Skip to content
Product block

Resumable Large Upload

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.

237 lineslucide-reactAdded 27 Aug 2026Updated 2 Sept 2026
  • upload
  • resumable
  • chunked
  • progress
  • offline

What's included

  • components/upload-resumable.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add upload-resumable

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

site-backup-2026-08-27.tar.zst

4.1 GB · 82 chunks · 2 retried

28%

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.

Source

components/upload-resumable.tsx
'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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/upload-resumable.tsx and import it where you need the section:

import { UploadResumable } from '@/components/upload-resumable'

Customize

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.

Props

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.

PropTypeDefault
fileNamestring'site-backup-2026-08-27.tar.zst'
totalBytesnumber4.1 * GB
chunkBytesnumber0.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'
classNamestring''

Not using React?

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.

upload-resumable.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

6 more blocks in File Upload

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 Upload

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More File Upload blocks

View category
Open the full page for this block

Drag & Drop Upload

Dropzone with per-file progress and a drag counter that fixes the flicker every naive dragleave handler produces.

File Upload219 lines1 dep
Open the full page for this block

Upload Progress List

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.

File Upload171 lines1 dep
Open the full page for this block

CSV Column Mapper

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.

File Upload280 lines1 dep
Open the full page for this block

Avatar Crop & Upload

Framing a profile photo with keyboard-operable zoom and position, showing what gets trimmed rather than hiding it behind the mask.

File Upload268 lines1 dep
Open the full page for this block

Upload Requirements Form

An upload form that states the constraints before the file is chosen, rather than rejecting it after a two-minute transfer.

File Upload184 linesNo deps
Open the full page for this block

Import From URL Form

The import path for a file that is already on the internet, which does not need to travel through the browser at all.

File Upload178 linesNo deps