# Upload Stack

> Uploads stacked like notifications that fan out on hover or focus.

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

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

## Files

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

```tsx
"use client"

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

import {
  Upload,
  UploadDropzone,
  UploadDropzoneContent,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneTitle,
  UploadItem,
  UploadItemActions,
  UploadItemContent,
  UploadItemDescription,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemTitle,
  UploadTrigger,
} from "@/registry/default/ui/upload"

const CARD_HEIGHT = 64
const GAP = 8
const PEEK = 10
const VISIBLE = 3

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

/**
 * Uploads stacked like notifications. Hover (or focus) the stack to fan it
 * out; the newest file is always on top.
 */
function UploadStack<TResult = unknown>({
  adapter,
  uploader,
  className,
  ...options
}: UploadStackProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : {
            adapter,
            ...options,
          }) as UploadRootProps<TResult>)}
      className={className}
    >
      <UploadDropzone orientation="horizontal" size="sm" clickable={false}>
        <UploadDropzoneHeader className="items-start">
          <UploadDropzoneTitle>Uploads</UploadDropzoneTitle>
          <UploadDropzoneDescription>
            Drop files here. Hover the stack to expand it.
          </UploadDropzoneDescription>
        </UploadDropzoneHeader>
        <UploadDropzoneContent>
          <UploadTrigger>Add files</UploadTrigger>
        </UploadDropzoneContent>
      </UploadDropzone>
      <Stack />
    </Upload>
  )
}

function Stack() {
  const { uploader } = useUploadContext()
  const items = useUploadSelector(uploader, (state) => state.items)
  const reduceMotion = useReducedMotion()
  const [expanded, setExpanded] = React.useState(false)

  const visible = React.useMemo(
    () => items.filter((item) => item.status !== "cancelled").reverse(),
    [items]
  )
  if (visible.length === 0) return null

  const count = visible.length
  const height = expanded
    ? count * CARD_HEIGHT + (count - 1) * GAP
    : CARD_HEIGHT + Math.min(count - 1, VISIBLE - 1) * PEEK

  return (
    <motion.ul
      role="list"
      aria-label="Uploads"
      className="relative w-full"
      initial={false}
      animate={{ height }}
      transition={
        reduceMotion
          ? { duration: 0 }
          : { type: "spring", bounce: 0.15, duration: 0.45 }
      }
      onMouseEnter={() => setExpanded(true)}
      onMouseLeave={() => setExpanded(false)}
      onFocus={() => setExpanded(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node)) {
          setExpanded(false)
        }
      }}
    >
      {visible.map((item, index) => {
        const hidden = !expanded && index >= VISIBLE
        return (
          <UploadItemContext.Provider key={item.id} value={item}>
            <UploadItem
              item={item}
              className="absolute inset-x-0 top-0 h-16 bg-card shadow-sm"
              render={
                <motion.li
                  initial={
                    reduceMotion ? false : { opacity: 0, y: -24, scale: 0.96 }
                  }
                  animate={{
                    opacity: hidden ? 0 : 1,
                    y: expanded ? index * (CARD_HEIGHT + GAP) : index * PEEK,
                    scale: expanded ? 1 : 1 - Math.min(index, VISIBLE) * 0.05,
                  }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { type: "spring", bounce: 0.2, duration: 0.5 }
                  }
                  style={{
                    zIndex: count - index,
                    transformOrigin: "top center",
                  }}
                  inert={hidden}
                />
              }
            >
              <UploadItemMedia />
              <UploadItemContent>
                <UploadItemTitle />
                <UploadItemDescription className="line-clamp-1" />
              </UploadItemContent>
              <UploadItemActions />
              <UploadItemProgress className="absolute inset-x-3 bottom-1.5 h-0.5 w-auto" />
            </UploadItem>
          </UploadItemContext.Provider>
        )
      })}
    </motion.ul>
  )
}

export { UploadStack, type UploadStackProps }
```
