# Vinyl Upload

> A record that spins while tracks upload, with a tonearm that follows progress.

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

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

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/vinyl-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/vinyl-upload.tsx`

```tsx
"use client"

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

import { IconPlaceholder } from "@/components/icon-placeholder"
import { Upload, UploadDropzone } from "@/registry/default/ui/upload"

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

const GROOVES = [98, 90, 82, 74, 66, 58, 50]

/**
 * For music and podcasts: the record spins up while tracks upload and coasts
 * to a stop when they're done, and the tonearm moves across the record with
 * the progress.
 */
function VinylUpload<TResult = unknown>({
  adapter,
  uploader,
  accept = "audio/*",
  className,
  ...options
}: VinylUploadProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : { adapter, accept, ...options }) as UploadRootProps<TResult>)}
      className={cn("w-full max-w-sm items-center gap-4", className)}
    >
      <UploadDropzone
        variant="muted"
        className="size-64 rounded-full bg-transparent p-0 hover:bg-transparent data-dragging:bg-transparent data-dragging:ring-0"
      >
        <Turntable />
      </UploadDropzone>
      <TrackList />
    </Upload>
  )
}

function Turntable() {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const reduceMotion = useReducedMotion()
  const percent = summary.isComplete ? 100 : summary.percent
  const playing = summary.isUploading && !reduceMotion

  // The record has momentum: it spins up, then coasts down.
  const rotation = useMotionValue(0)
  const speed = useSpring(0, { stiffness: 30, damping: 18 })
  React.useEffect(() => {
    speed.set(playing ? 1 : 0)
  }, [speed, playing])
  useAnimationFrame((_, delta) => {
    const velocity = speed.get()
    if (velocity > 0.002)
      rotation.set((rotation.get() + velocity * delta * 0.2) % 360)
  })

  // The tonearm rests off the record, then tracks inwards with progress.
  const idle = summary.total === 0
  const arm = useSpring(idle ? 28 : 0, { stiffness: 70, damping: 18 })
  React.useEffect(() => {
    const target = idle ? 28 : -percent * 0.2
    if (reduceMotion) arm.jump(target)
    else arm.set(target)
  }, [arm, idle, percent, reduceMotion])
  const armRotate = useTransform(arm, (value) => `rotate(${value} 214 30)`)

  return (
    <div className="relative size-64 transition-transform duration-300 group-data-dragging/upload-dropzone:scale-105 motion-reduce:transition-none">
      <svg viewBox="0 0 240 240" className="size-full" aria-hidden>
        <circle cx="120" cy="120" r="118" className="fill-muted" />
        <motion.g style={{ rotate: rotation }}>
          <circle
            cx="120"
            cy="120"
            r="106"
            className="fill-foreground dark:fill-background"
          />
          {GROOVES.map((radius) => (
            <circle
              key={radius}
              cx="120"
              cy="120"
              r={radius}
              fill="none"
              strokeWidth="1"
              className="stroke-background/15 dark:stroke-foreground/15"
            />
          ))}
          {/* A sheen, so the spin is visible */}
          <path
            d="M120 22 A98 98 0 0 1 205 71"
            fill="none"
            strokeWidth="6"
            strokeLinecap="round"
            className="stroke-background/20 dark:stroke-foreground/20"
          />
          <circle cx="120" cy="120" r="36" className="fill-primary" />
          <circle
            cx="120"
            cy="96"
            r="3"
            className="fill-primary-foreground/60"
          />
        </motion.g>
        <motion.g transform={armRotate}>
          <circle
            cx="214"
            cy="30"
            r="12"
            className="fill-muted-foreground/40"
          />
          <circle cx="214" cy="30" r="5" className="fill-muted-foreground" />
          <path
            d="M214 30 L196 150 L176 176"
            fill="none"
            strokeWidth="5"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="stroke-muted-foreground"
          />
          <rect
            x="166"
            y="170"
            width="18"
            height="12"
            rx="3"
            transform="rotate(-50 175 176)"
            className="fill-muted-foreground"
          />
        </motion.g>
      </svg>
      <div className="absolute top-1/2 left-1/2 flex size-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full text-primary-foreground">
        {idle ? (
          <IconPlaceholder
            lucide="PlusIcon"
            tabler="IconPlus"
            hugeicons="Add01Icon"
            phosphor="PlusIcon"
            remixicon="RiAddLine"
            className="size-6"
          />
        ) : summary.isComplete ? (
          <IconPlaceholder
            lucide="CheckIcon"
            tabler="IconCheck"
            hugeicons="Tick02Icon"
            phosphor="CheckIcon"
            remixicon="RiCheckLine"
            className="size-6"
          />
        ) : (
          <span className="text-sm font-semibold tabular-nums">
            {Math.round(percent)}%
          </span>
        )}
      </div>
      <span className="sr-only">
        {idle
          ? "Add tracks: drop audio files or click to browse"
          : "Add more tracks"}
      </span>
    </div>
  )
}

function TrackList() {
  const { uploader } = useUploadContext()
  const items = useUploadSelector(uploader, (state) =>
    state.items.filter((item) => item.status !== "cancelled")
  )
  const summary = useUploadProgress(uploader)
  if (items.length === 0) {
    return (
      <p className="text-center text-sm text-muted-foreground">
        Drop tracks on the record, or click it
      </p>
    )
  }
  return (
    <div className="flex w-full flex-col gap-2">
      <ol className="flex flex-col divide-y rounded-xl border bg-card">
        {items.map((item, index) => (
          <Track key={item.id} item={item} index={index} />
        ))}
      </ol>
      <p
        className="text-center text-xs text-muted-foreground tabular-nums"
        aria-live="polite"
      >
        Side A · {summary.counts.success} of {summary.total} tracks ·{" "}
        {formatBytes(summary.loaded)} of {formatBytes(summary.size)}
      </p>
    </div>
  )
}

function Track({ item, index }: { item: UploadItemData; index: number }) {
  const reduceMotion = useReducedMotion()
  const playing = item.status === "uploading" && !reduceMotion
  const failed = item.status === "error" || item.status === "rejected"
  return (
    <li className="flex items-center gap-3 px-3 py-2 text-sm">
      <span className="w-5 text-right text-xs text-muted-foreground tabular-nums">
        {index + 1}
      </span>
      <span className="min-w-0 flex-1 truncate">
        {item.file.name.replace(/\.[^.]+$/, "")}
      </span>
      <span aria-hidden className="flex h-3.5 items-end gap-0.5">
        {[0.5, 1, 0.7].map((height, bar) => (
          <motion.span
            key={bar}
            className={cn(
              "w-0.5 rounded-full bg-primary",
              !playing && "bg-muted-foreground/40"
            )}
            style={{ height: "100%", originY: 1 }}
            initial={false}
            animate={
              playing
                ? { scaleY: [height, 0.25, 1, height] }
                : { scaleY: item.status === "success" ? 0.35 : 0.2 }
            }
            transition={
              playing
                ? { duration: 0.9, repeat: Infinity, delay: bar * 0.15 }
                : { duration: 0.2 }
            }
          />
        ))}
      </span>
      <span
        className={cn(
          "w-10 text-right text-xs text-muted-foreground tabular-nums",
          failed && "text-destructive"
        )}
      >
        {failed
          ? "Failed"
          : item.status === "success"
            ? "Done"
            : `${Math.round(item.progress.percent)}%`}
      </span>
    </li>
  )
}

export { VinylUpload, type VinylUploadProps }
```
