# Battery Upload

> Charges cell by cell for backups, with a bolt that pulses while it charges.

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

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

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/battery-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/battery-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 BatteryUploadProps<TResult> extends Pick<
  ValidationOptions,
  "accept" | "maxSize" | "maxFiles"
> {
  adapter?: UploadAdapter<TResult>
  /** Use an uploader you created with `useUploader` instead of options. */
  uploader?: Uploader<TResult>
  /** What is being backed up, e.g. "photos". Default "files". */
  noun?: string
  onSuccess?: (item: UploadItemData<TResult>) => void
  className?: string
}

const CELLS = 5

/**
 * For device and photo backups: a battery charges cell by cell as files
 * upload, with a pulsing bolt while it's charging and a full glow when the
 * backup is done.
 */
function BatteryUpload<TResult = unknown>({
  adapter,
  uploader,
  noun = "files",
  accept = ["image/*", "video/*"],
  className,
  ...options
}: BatteryUploadProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : { adapter, accept, ...options }) as UploadRootProps<TResult>)}
      className={cn("w-full max-w-sm items-center gap-5", className)}
    >
      <UploadDropzone
        variant="muted"
        className="h-44 rounded-2xl bg-transparent p-0 hover:bg-muted/40 data-dragging:bg-muted/60"
      >
        <Battery />
      </UploadDropzone>
      <BatteryCaption noun={noun} />
    </Upload>
  )
}

function Battery() {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const reduceMotion = useReducedMotion()
  const percent = summary.isComplete ? 100 : summary.percent
  const level = useSpring(percent, { stiffness: 80, damping: 20 })
  React.useEffect(() => {
    if (reduceMotion) level.jump(percent)
    else level.set(percent)
  }, [level, percent, reduceMotion])
  const label = useTransform(level, (value) => `${Math.round(value)}`)
  const charging = summary.isUploading
  const failed = summary.hasErrors && !summary.isUploading

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex items-center transition-transform duration-300 group-data-dragging/upload-dropzone:scale-105 motion-reduce:transition-none">
        <motion.div
          className={cn(
            "relative flex h-24 w-56 gap-1.5 rounded-2xl border-4 border-foreground p-1.5",
            summary.isComplete && "shadow-lg shadow-primary/30"
          )}
          animate={
            summary.isComplete && !reduceMotion
              ? { scale: [1, 1.04, 1] }
              : undefined
          }
          transition={{ duration: 0.5 }}
        >
          {Array.from({ length: CELLS }, (_, index) => (
            <Cell key={index} index={index} level={level} failed={failed} />
          ))}
          <AnimatePresence>
            {charging ? (
              <motion.svg
                key="bolt"
                viewBox="0 0 24 24"
                aria-hidden
                className="absolute top-1/2 left-1/2 size-12 -translate-x-1/2 -translate-y-1/2"
                initial={{ opacity: 0, scale: 0.6 }}
                animate={
                  reduceMotion
                    ? { opacity: 1, scale: 1 }
                    : { opacity: [0.85, 1, 0.85], scale: [1, 1.12, 1] }
                }
                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.2 } }}
                transition={{
                  duration: 1.1,
                  repeat: reduceMotion ? 0 : Infinity,
                }}
              >
                <path
                  d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"
                  strokeWidth="1.5"
                  strokeLinejoin="round"
                  className="fill-background stroke-foreground"
                />
              </motion.svg>
            ) : null}
          </AnimatePresence>
        </motion.div>
        <div className="h-9 w-2.5 rounded-r-md bg-foreground" />
      </div>
      <div className="flex items-baseline text-3xl font-semibold tracking-tight tabular-nums">
        <motion.span>{label}</motion.span>
        <span className="text-base text-muted-foreground">%</span>
      </div>
      <span className="sr-only">
        Drop files to back them up, or click to choose
      </span>
    </div>
  )
}

function Cell({
  index,
  level,
  failed,
}: {
  index: number
  level: ReturnType<typeof useSpring>
  failed: boolean
}) {
  const share = 100 / CELLS
  const fill = useTransform(level, (value) =>
    Math.min(1, Math.max(0, (value - index * share) / share))
  )
  return (
    <div className="relative flex-1 overflow-hidden rounded-md bg-muted">
      <motion.div
        className={cn(
          "absolute inset-0 origin-left",
          failed ? "bg-destructive" : "bg-primary"
        )}
        style={{ scaleX: fill }}
      />
    </div>
  )
}

function BatteryCaption({ noun }: { noun: string }) {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const eta = formatDuration(summary.eta)
  return (
    <p
      className="text-center text-sm text-muted-foreground tabular-nums"
      aria-live="polite"
    >
      {summary.total === 0
        ? `Drop ${noun} to back them up, or click the battery`
        : summary.isComplete
          ? `Backup complete · ${summary.counts.success} ${noun} · ${formatBytes(summary.size)}`
          : summary.hasErrors && !summary.isUploading
            ? `${summary.counts.error} ${noun} couldn't be backed up`
            : `Backing up ${summary.counts.success} of ${summary.total} ${noun} · ${formatBytes(summary.loaded)} of ${formatBytes(summary.size)}${
                eta ? ` · ${eta} left` : ""
              }`}
    </p>
  )
}

export { BatteryUpload, type BatteryUploadProps }
```
