# Upload Odometer

> Files, megabytes and percent on rolling odometer digits.

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

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/upload-odometer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/upload-odometer-light-7eee3bd8020d61c9.png
- Registry item: https://uploadcn.dev/r/new-york-v4/upload-odometer.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/upload-odometer.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/upload-odometer.tsx`

```tsx
"use client"

import * as React from "react"
import {
  formatSpeed,
  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 { motion, useReducedMotion } from "motion/react"

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

interface UploadOdometerProps<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
}

/**
 * Upload stats on rolling digits, files, megabytes and percent tick
 * over like an odometer. Pairs well with dashboards and batch jobs.
 */
function UploadOdometer<TResult = unknown>({
  adapter,
  uploader,
  className,
  ...options
}: UploadOdometerProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : { adapter, ...options }) as UploadRootProps<TResult>)}
      className={className}
    >
      <Stats />
      <UploadProgress />
      <UploadDropzone size="sm" variant="muted">
        <UploadDropzoneTitle>Drop a batch of files</UploadDropzoneTitle>
        <UploadDropzoneDescription>
          Watch the counters roll
        </UploadDropzoneDescription>
      </UploadDropzone>
    </Upload>
  )
}

function Digit({ value }: { value: number }) {
  const reduceMotion = useReducedMotion()
  return (
    <span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        animate={{ y: `${-value * 10}%` }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : { type: "spring", bounce: 0.15, duration: 0.6 }
        }
      >
        {Array.from({ length: 10 }, (_, digit) => (
          <span
            key={digit}
            className="flex h-[1em] items-center justify-center"
          >
            {digit}
          </span>
        ))}
      </motion.span>
    </span>
  )
}

function Rolling({ value, digits }: { value: number; digits: number }) {
  const text = Math.max(0, Math.floor(value)).toString().padStart(digits, "0")
  return (
    <span className="inline-flex leading-none tabular-nums" aria-hidden>
      {text.split("").map((char, index) => (
        <Digit key={text.length - index} value={Number(char)} />
      ))}
    </span>
  )
}

function Stat({
  label,
  value,
  digits,
  unit,
  className,
}: {
  label: string
  value: number
  digits: number
  unit?: string
  className?: string
}) {
  return (
    <div className={cn("ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) rounded-xl text-sm ring-1 [--card-spacing:--spacing(4)] p-(--card-spacing) flex flex-col gap-1.5", className)}>
      <span className="text-xs text-muted-foreground">{label}</span>
      <span className="flex items-baseline gap-1 text-3xl font-semibold tracking-tight">
        <Rolling value={value} digits={digits} />
        {unit ? (
          <span className="text-sm font-normal text-muted-foreground">
            {unit}
          </span>
        ) : null}
        <span className="sr-only">
          {Math.floor(value)} {unit}
        </span>
      </span>
    </div>
  )
}

function Stats() {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  return (
    <div className="flex flex-col gap-2">
      <div className="grid grid-cols-3 gap-2">
        <Stat label="Files" value={summary.counts.success} digits={2} />
        <Stat
          label="Uploaded"
          value={summary.loaded / 1_000_000}
          digits={3}
          unit="MB"
        />
        <Stat
          label="Progress"
          value={summary.isComplete ? 100 : summary.percent}
          digits={3}
          unit="%"
        />
      </div>
      <p
        className="h-4 text-xs text-muted-foreground tabular-nums"
        aria-live="off"
      >
        {summary.isUploading && summary.speed
          ? formatSpeed(summary.speed)
          : null}
      </p>
    </div>
  )
}

export { UploadOdometer, type UploadOdometerProps }
```
