# Ripple Dropzone

> A dropzone whose rings ripple while dragging, with a burst on drop.

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

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

```tsx
"use client"

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

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

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

/**
 * A dropzone with a pulse: rings ripple out while files are dragged over it,
 * and a burst confirms the drop.
 */
function RippleDropzone<TResult = unknown>({
  adapter,
  uploader,
  title = "Drop to upload",
  description = "Drag files here, or click to browse",
  className,
  ...options
}: RippleDropzoneProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : {
            adapter,
            ...options,
          }) as UploadRootProps<TResult>)}
      className={className}
    >
      <UploadDropzone
        variant="muted"
        size="lg"
        render={(props, state) => (
          <div {...props}>
            <Ripples dragging={state.isDragging} reject={state.isDragReject} />
            {props.children as React.ReactNode}
          </div>
        )}
      >
        <UploadDropzoneTitle className="mt-2">{title}</UploadDropzoneTitle>
        <UploadDropzoneDescription>{description}</UploadDropzoneDescription>
      </UploadDropzone>
      <UploadQueue />
    </Upload>
  )
}

function Ripples({ dragging, reject }: { dragging: boolean; reject: boolean }) {
  const { uploader } = useUploadContext()
  const reduceMotion = useReducedMotion()
  const [bursts, setBursts] = React.useState<number[]>([])

  React.useEffect(
    () =>
      uploader.on("add", () => {
        if (!reduceMotion) setBursts((current) => [...current, Date.now()])
      }),
    [uploader, reduceMotion]
  )

  return (
    <div
      aria-hidden
      className="relative flex size-14 items-center justify-center"
    >
      {dragging && !reduceMotion
        ? [0, 1, 2].map((ring) => (
            <motion.span
              key={ring}
              className={cn(
                "absolute inset-0 rounded-full border",
                reject ? "border-destructive/60" : "border-primary/40"
              )}
              initial={{ scale: 1, opacity: 0.6 }}
              animate={{ scale: 2.6, opacity: 0 }}
              transition={{
                duration: 1.6,
                repeat: Infinity,
                delay: ring * 0.5,
                ease: "easeOut",
              }}
            />
          ))
        : null}
      <AnimatePresence>
        {bursts.map((burst) => (
          <motion.span
            key={burst}
            className="absolute inset-0 rounded-full bg-primary/25"
            initial={{ scale: 0.8, opacity: 0.8 }}
            animate={{ scale: 3, opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.7, ease: "easeOut" }}
            onAnimationComplete={() =>
              setBursts((current) => current.filter((item) => item !== burst))
            }
          />
        ))}
      </AnimatePresence>
      <motion.span
        className={cn(
          "relative flex size-14 items-center justify-center rounded-full border bg-background shadow-sm transition-colors",
          dragging && !reject && "border-primary text-foreground",
          reject && "border-destructive text-destructive"
        )}
        animate={reduceMotion ? undefined : { scale: dragging ? 1.12 : 1 }}
        transition={{ type: "spring", bounce: 0.45, duration: 0.5 }}
      >
        <motion.span
          className="flex"
          animate={reduceMotion || !dragging ? { y: 0 } : { y: [0, -4, 0] }}
          transition={
            dragging
              ? { repeat: Infinity, duration: 0.9, ease: "easeInOut" }
              : { duration: 0.2 }
          }
        >
          <IconPlaceholder
            lucide="ArrowUpIcon"
            tabler="IconArrowUp"
            hugeicons="ArrowUpIcon"
            phosphor="ArrowUpIcon"
            remixicon="RiArrowUpLine"
            className="size-5"
          />
        </motion.span>
      </motion.span>
    </div>
  )
}

export { RippleDropzone, type RippleDropzoneProps }
```
