# Hourglass Upload

> Sand runs through an hourglass as files upload, with the time left up front.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/hourglass-upload).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/hourglass-upload
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/hourglass-upload-light-35703476cb47c9d5.png
- Registry item: https://uploadcn.dev/r/new-york-v4/hourglass-upload.json
- Categories: upload, animated
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/hourglass-upload.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`, `@uploadcn/react`, `cn`, `motion`
- Registry items: `@uploadcn/upload`

## Files

### `registry/default/blocks/hourglass-upload.tsx`

```tsx
"use client"

import * as React from "react"
import {
  formatBytes,
  formatDuration,
  type UploadAdapter,
  type Uploader,
  type UploadItem as UploadItemData,
  type ValidationOptions,
} from "@uploadcn/core"
import {
  useUploadContext,
  useUploadProgress,
  type UploadRootProps,
} from "@uploadcn/react"
import { cn } from "cn"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react"

import { Upload, UploadDropzone } from "@/registry/default/ui/upload"

interface HourglassUploadProps<TResult> extends Pick<
  ValidationOptions,
  "accept" | "maxSize" | "maxFiles"
> {
  adapter?: UploadAdapter<TResult>
  /** Use an uploader you created with `useUploader` instead of options. */
  uploader?: Uploader<TResult>
  onSuccess?: (item: UploadItemData<TResult>) => void
  className?: string
}

// Hourglass geometry (viewBox 120 × 180)
const TOP = 22
const NECK = 90
const BOTTOM = 158
const BULB = NECK - TOP

/**
 * For long uploads and deadlines: sand runs from the top bulb to the bottom
 * as the upload progresses, and the time left is front and center.
 */
function HourglassUpload<TResult = unknown>({
  adapter,
  uploader,
  className,
  ...options
}: HourglassUploadProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : { adapter, ...options }) as UploadRootProps<TResult>)}
      className={cn("w-full max-w-xs items-center gap-4", className)}
    >
      <UploadDropzone
        variant="muted"
        className="h-64 w-48 rounded-3xl bg-transparent p-0 hover:bg-muted/40 data-dragging:bg-muted/60"
      >
        <Hourglass />
      </UploadDropzone>
      <TimeLeft />
    </Upload>
  )
}

function Hourglass() {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const reduceMotion = useReducedMotion()
  const id = `hourglass${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`
  const percent = summary.isComplete ? 100 : summary.percent
  const sand = useSpring(percent, { stiffness: 50, damping: 18 })
  React.useEffect(() => {
    if (reduceMotion) sand.jump(percent)
    else sand.set(percent)
  }, [sand, percent, reduceMotion])

  // Top sand drains from the surface down; bottom sand piles up.
  const topY = useTransform(sand, (value) => TOP + (value / 100) * BULB)
  const bottomY = useTransform(sand, (value) => BOTTOM - (value / 100) * BULB)
  const flowing = summary.isUploading && percent < 100
  const done = summary.isComplete

  return (
    <motion.div
      className="relative"
      animate={
        done && !reduceMotion ? { rotate: [0, -8, 6, 0] } : { rotate: 0 }
      }
      transition={{ duration: 0.8 }}
    >
      <svg viewBox="0 0 120 180" className="h-56 w-auto" aria-hidden>
        <defs>
          <clipPath id={`${id}-top`}>
            <path
              d={`M24 ${TOP} H96 C96 60 66 76 62 ${NECK} H58 C54 76 24 60 24 ${TOP} Z`}
            />
          </clipPath>
          <clipPath id={`${id}-bottom`}>
            <path
              d={`M58 ${NECK} H62 C66 104 96 120 96 ${BOTTOM} H24 C24 120 54 104 58 ${NECK} Z`}
            />
          </clipPath>
        </defs>

        {/* Sand */}
        <g clipPath={`url(#${id}-top)`}>
          <motion.rect
            x="20"
            width="80"
            height={BULB + 10}
            y={topY}
            className="fill-primary"
          />
        </g>
        <g clipPath={`url(#${id}-bottom)`}>
          <motion.rect
            x="20"
            width="80"
            height={BULB + 10}
            y={bottomY}
            className="fill-primary"
          />
        </g>
        {flowing ? (
          <motion.line
            x1="60"
            x2="60"
            y1={NECK - 2}
            y2={BOTTOM - 4}
            strokeWidth="2"
            strokeDasharray="3 5"
            className="stroke-primary"
            animate={reduceMotion ? undefined : { strokeDashoffset: [0, -16] }}
            transition={{ duration: 0.5, ease: "linear", repeat: Infinity }}
          />
        ) : null}

        {/* Glass */}
        <path
          d={`M24 ${TOP} H96 C96 60 66 76 62 ${NECK} C66 104 96 120 96 ${BOTTOM} H24 C24 120 54 104 58 ${NECK} C54 76 24 60 24 ${TOP} Z`}
          fill="none"
          strokeWidth="3"
          strokeLinejoin="round"
          className="stroke-foreground/70"
        />
        {/* Frame */}
        <rect
          x="12"
          y="8"
          width="96"
          height="14"
          rx="5"
          className="fill-foreground"
        />
        <rect
          x="12"
          y={BOTTOM}
          width="96"
          height="14"
          rx="5"
          className="fill-foreground"
        />
        <rect
          x="16"
          y="22"
          width="5"
          height={BOTTOM - 22}
          rx="2"
          className="fill-foreground/60"
        />
        <rect
          x="99"
          y="22"
          width="5"
          height={BOTTOM - 22}
          rx="2"
          className="fill-foreground/60"
        />
      </svg>
      <AnimatePresence>
        {done ? (
          <motion.span
            className="absolute -top-1 -right-3 flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md"
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            exit={{ scale: 0 }}
            transition={{
              type: "spring",
              stiffness: 300,
              damping: 18,
              delay: 0.3,
            }}
          >
            <svg
              viewBox="0 0 24 24"
              className="size-5"
              fill="none"
              stroke="currentColor"
              strokeWidth="3"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M5 12.5l4.5 4.5L19 7.5" />
            </svg>
          </motion.span>
        ) : null}
      </AnimatePresence>
      <span className="sr-only">Drop files or click to upload</span>
    </motion.div>
  )
}

function TimeLeft() {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const eta = formatDuration(summary.eta)
  return (
    <div
      className="flex flex-col items-center gap-1 text-center"
      aria-live="polite"
    >
      <p className="text-2xl font-semibold tracking-tight tabular-nums">
        {summary.total === 0
          ? "Time to upload"
          : summary.isComplete
            ? "Done in time"
            : summary.hasErrors && !summary.isUploading
              ? "Interrupted"
              : eta
                ? `${eta} left`
                : `${Math.round(summary.percent)}%`}
      </p>
      <p className="text-sm text-muted-foreground tabular-nums">
        {summary.total === 0
          ? "Drop files on the hourglass, or click it"
          : `${summary.counts.success} of ${summary.total} files · ${formatBytes(summary.loaded)} of ${formatBytes(summary.size)}`}
      </p>
    </div>
  )
}

export { HourglassUpload, type HourglassUploadProps }
```
