# Dropbox Upload

A shadcn/ui block from the [@uui](https://uui.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uui/dropbox-upload).

- Page, with a live preview: https://shadcn.exchange/r/uui/dropbox-upload
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uui/dropbox-upload-light-059119ab70f6e882.png
- Registry item: https://uui.app/r/dropbox-upload.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uui.app/r/dropbox-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: `motion`

## Files

### `components/uui/dropbox-upload/dropbox-upload.tsx`

```tsx
'use client';

import * as React from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Upload, CheckCircle2, ChevronDown, ChevronUp, RefreshCw, AlertCircle } from 'lucide-react';

interface UploadItem {
  id: string;
  file: File;
  progress: number;
  status: 'uploading' | 'complete' | 'error';
}

export interface DropboxUploadProps {
  className?: string;
  onUploadStart?: (files: File[]) => void;
  onUploadComplete?: (file: File) => void;
  simulateDelay?: number;
  maxFiles?: number;
}

export function DropboxUpload({
  className,
  onUploadStart,
  onUploadComplete,
  simulateDelay = 300,
  maxFiles,
}: DropboxUploadProps) {
  const [uploads, setUploads] = React.useState<UploadItem[]>([]);
  const [isDragOver, setIsDragOver] = React.useState(false);
  const [isPanelOpen, setIsPanelOpen] = React.useState(false);
  const [isMinimized, setIsMinimized] = React.useState(false);
  const [filter, setFilter] = React.useState<'all' | 'completed' | 'skipped' | 'failed'>('all');

  const dragCounter = React.useRef(0);
  const cancelFns = React.useRef<Map<string, () => void>>(new Map());
  const fileInputRef = React.useRef<HTMLInputElement>(null);

  const addUpload = React.useCallback(
    (files: FileList | File[]) => {
      const fileArray = Array.from(files);
      let items = fileArray.map(
        (file): UploadItem => ({
          id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
          file,
          progress: 0,
          status: 'uploading',
        }),
      );

      if (maxFiles) {
        items = items.slice(0, maxFiles);
      }

      setUploads((prev) => [...prev, ...items]);
      setIsPanelOpen(true);
      setIsMinimized(false); // Auto-expand when new uploads start
      onUploadStart?.(items.map((i) => i.file));

      for (const item of items) {
        let progress = 0;
        const interval = setInterval(() => {
          const increment = Math.random() * 15 + 5;
          progress = Math.min(100, progress + increment);
          const p = Math.round(progress);
          setUploads((prev) => prev.map((u) => (u.id === item.id ? { ...u, progress: p } : u)));
          if (progress >= 100) {
            clearInterval(interval);
            cancelFns.current.delete(item.id);
            setUploads((prev) => prev.map((u) => (u.id === item.id ? { ...u, status: 'complete' } : u)));
            onUploadComplete?.(item.file);
          }
        }, simulateDelay);
        cancelFns.current.set(item.id, () => clearInterval(interval));
      }
    },
    [maxFiles, onUploadStart, onUploadComplete, simulateDelay],
  );

  const removeUpload = React.useCallback((id: string) => {
    cancelFns.current.get(id)?.();
    cancelFns.current.delete(id);
    setUploads((prev) => prev.filter((u) => u.id !== id));
  }, []);

  const cancelAll = React.useCallback(() => {
    uploads.forEach((u) => {
      if (u.status === 'uploading') {
        cancelFns.current.get(u.id)?.();
        cancelFns.current.delete(u.id);
      }
    });
    setUploads([]);
    setIsPanelOpen(false);
  }, [uploads]);

  const clearAll = React.useCallback(() => {
    setUploads([]);
    setIsPanelOpen(false);
  }, []);

  const handleDragEnter = React.useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    dragCounter.current++;
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      setIsDragOver(true);
    }
  }, []);

  const handleDragLeave = React.useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    dragCounter.current--;
    if (dragCounter.current === 0) {
      setIsDragOver(false);
    }
  }, []);

  const handleDrop = React.useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      e.stopPropagation();
      setIsDragOver(false);
      dragCounter.current = 0;
      if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
        addUpload(e.dataTransfer.files);
      }
    },
    [addUpload],
  );

  // Filtered uploads logic
  const filteredUploads = React.useMemo(() => {
    return uploads.filter((u) => {
      if (filter === 'all') return true;
      if (filter === 'completed') return u.status === 'complete';
      if (filter === 'failed') return u.status === 'error';
      return false; // 'skipped' or fallback
    });
  }, [uploads, filter]);

  // Statistics for the blue footer bar
  const uploadingItems = React.useMemo(() => uploads.filter((u) => u.status === 'uploading'), [uploads]);
  const uploadingCount = uploadingItems.length;

  const secondsLeft = React.useMemo(() => {
    if (uploadingCount === 0) return 0;
    const avgProgress = uploadingItems.reduce((acc, curr) => acc + curr.progress, 0) / uploadingCount;
    const pctRemaining = 100 - avgProgress;
    // Each step is simulateDelay (ms) and averages ~10% progress increment
    const stepsLeft = pctRemaining / 10;
    return Math.max(1, Math.round((stepsLeft * simulateDelay) / 1000));
  }, [uploadingItems, uploadingCount, simulateDelay]);

  return (
    <div
      className={`relative h-svh w-full flex flex-col items-center justify-center bg-[#0a0a0a] ${className ?? ''}`}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={(e) => {
        e.preventDefault();
        e.stopPropagation();
      }}
      onDrop={handleDrop}
    >
      {/* Hidden File Input */}
      <input
        type="file"
        ref={fileInputRef}
        onChange={(e) => {
          if (e.target.files && e.target.files.length > 0) {
            addUpload(e.target.files);
          }
        }}
        multiple
        className="hidden"
      />

      {/* Main Drop Zone */}
      <div
        onClick={() => fileInputRef.current?.click()}
        className={`
          relative flex flex-col items-center justify-center gap-4
          w-full max-w-lg h-64 rounded-2xl border-2 border-dashed
          transition-all duration-300 cursor-pointer select-none
          ${isDragOver
            ? 'border-blue-500 bg-blue-500/5 scale-[1.02] shadow-[0_0_20px_rgba(59,130,246,0.15)]'
            : 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/50'
          }
        `}
      >
        <motion.div
          animate={isDragOver ? { scale: 1.1, y: -4 } : { scale: 1, y: 0 }}
          transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
        >
          <Upload className={`size-12 ${isDragOver ? 'text-blue-500' : 'text-zinc-500'}`} />
        </motion.div>
        <div className="text-center">
          <p className={`text-sm font-semibold ${isDragOver ? 'text-blue-500' : 'text-zinc-300'}`}>
            {isDragOver ? 'Drop files here' : 'Drag & drop files here'}
          </p>
          <p className="text-xs text-zinc-500 mt-1">or click to browse</p>
        </div>
      </div>

      {/* Uploads Panel (Popper) */}
      <AnimatePresence>
        {isPanelOpen && uploads.length > 0 && (
          <motion.div
            initial={{ opacity: 0, y: 20, scale: 0.95 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 20, scale: 0.95 }}
            transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
            className="fixed bottom-4 right-4 z-50 w-96 bg-[#141414] text-[#f5f5f5] rounded-2xl shadow-[0_12px_40px_rgba(0,0,0,0.6)] border border-zinc-800/80 overflow-hidden flex flex-col font-sans"
          >
            {!isMinimized && (
              <>
                {/* Header */}
                <div className="flex items-center justify-between px-4 pt-4 pb-2">
                  <span className="text-base font-bold text-white">Uploads</span>
                  <div className="flex items-center gap-2">
                    <button
                      onClick={uploadingCount > 0 ? cancelAll : clearAll}
                      className="text-xs font-semibold px-3 py-1.5 rounded-full border border-zinc-800 hover:border-zinc-700 text-zinc-200 hover:text-white transition-colors bg-transparent cursor-pointer"
                    >
                      {uploadingCount > 0 ? 'Cancel all' : 'Clear all'}
                    </button>
                    <button
                      onClick={() => setIsMinimized(true)}
                      className="text-zinc-400 hover:text-white p-1 hover:bg-zinc-800/50 rounded-full transition-colors cursor-pointer"
                      title="Minimize"
                    >
                      <ChevronDown className="size-4.5" />
                    </button>
                  </div>
                </div>

                {/* Filter Pills */}
                <div className="flex gap-1.5 px-4 py-2 flex-wrap">
                  {(['all', 'completed', 'skipped', 'failed'] as const).map((tab) => {
                    const label = {
                      all: 'All uploads',
                      completed: 'Completed',
                      skipped: 'Skipped',
                      failed: 'Failed',
                    }[tab];

                    const isActive = filter === tab;

                    return (
                      <button
                        key={tab}
                        onClick={() => setFilter(tab)}
                        className={`text-xs px-3 py-1 rounded-full border transition-all cursor-pointer ${
                          isActive
                            ? 'border-white text-white font-semibold bg-transparent'
                            : 'border-transparent bg-[#242424] text-zinc-400 hover:bg-[#2e2e2e] hover:text-zinc-200'
                        }`}
                      >
                        {label}
                      </button>
                    );
                  })}
                </div>

                {/* Upload Destination Link */}
                <div className="px-4 py-1.5 text-xs text-zinc-400 font-medium">
                  Uploading to{' '}
                  <span className="underline font-bold text-white cursor-pointer hover:text-blue-400 transition-colors">
                    Design Spells
                  </span>
                </div>

                {/* Items List */}
                <div className="max-h-72 overflow-y-auto mt-2 border-t border-zinc-900/60 divide-y divide-zinc-900/40">
                  {filteredUploads.length === 0 ? (
                    <div className="flex flex-col items-center justify-center py-10 text-zinc-500 text-xs">
                      No uploads in this filter
                    </div>
                  ) : (
                    filteredUploads.map((item) => {
                      const extension = item.file.name.split('.').pop()?.toUpperCase() || 'FILE';
                      const nameWithoutExtension =
                        item.file.name.substring(0, item.file.name.lastIndexOf('.')) || item.file.name;

                      return (
                        <div
                          key={item.id}
                          className="relative flex flex-col pt-3.5 pb-4.5 group"
                        >
                          <div className="flex items-start justify-between px-4 gap-3">
                            <div className="flex items-start gap-3 min-w-0 flex-1">
                              <div className="mt-0.5 shrink-0">
                                {item.status === 'complete' ? (
                                  <CheckCircle2 className="size-4.5 text-emerald-500 fill-emerald-500/10" />
                                ) : item.status === 'error' ? (
                                  <AlertCircle className="size-4.5 text-rose-500" />
                                ) : (
                                  <motion.div
                                    animate={{ rotate: 360 }}
                                    transition={{ repeat: Infinity, duration: 1.8, ease: 'linear' }}
                                    className="inline-block"
                                  >
                                    <RefreshCw className="size-4.5 text-zinc-400" />
                                  </motion.div>
                                )}
                              </div>
                              <div className="min-w-0 flex-1">
                                <span className="text-xs font-semibold text-white truncate block">
                                  {nameWithoutExtension}
                                </span>
                                <div className="flex items-center gap-1.5 mt-1">
                                  <span className="bg-[#242424] text-zinc-400 text-[10px] font-bold px-1.5 py-0.5 rounded leading-none">
                                    {extension}
                                  </span>
                                  <span className="text-[11px] text-zinc-400">
                                    {item.status === 'complete'
                                      ? 'Complete'
                                      : item.status === 'error'
                                      ? 'Failed'
                                      : item.progress > 90
                                      ? 'Finalizing...'
                                      : `${item.progress}%`}
                                  </span>
                                </div>
                              </div>
                            </div>

                            {item.status === 'uploading' && (
                              <button
                                onClick={() => removeUpload(item.id)}
                                className="text-zinc-400 hover:text-white bg-[#242424] hover:bg-[#2e2e2e] transition-colors shrink-0 text-xs px-3 py-1 rounded-full font-semibold cursor-pointer"
                              >
                                Cancel
                              </button>
                            )}
                          </div>

                          {/* Item Progress Bar */}
                          {item.status === 'uploading' && (
                            <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-zinc-900 overflow-hidden">
                              <motion.div
                                className="h-full bg-[#0061FE]"
                                initial={{ width: 0 }}
                                animate={{ width: `${item.progress}%` }}
                                transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
                              />
                            </div>
                          )}
                        </div>
                      );
                    })
                  )}
                </div>
              </>
            )}

            {/* Blue Status Footer */}
            <div
              className={`bg-[#0061FE] text-white px-4 py-3 flex items-center justify-between transition-all select-none ${
                isMinimized ? 'cursor-pointer hover:bg-[#0057e6]' : ''
              }`}
              onClick={() => {
                if (isMinimized) {
                  setIsMinimized(false);
                }
              }}
            >
              <div className="flex items-center gap-3">
                <div className="bg-[#0052d9] p-1.5 rounded-lg shrink-0">
                  {uploadingCount > 0 ? (
                    <motion.div
                      animate={{ rotate: 360 }}
                      transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
                      className="inline-block"
                    >
                      <RefreshCw className="size-4 text-white" />
                    </motion.div>
                  ) : (
                    <Upload className="size-4 text-white" />
                  )}
                </div>
                <div>
                  <p className="text-xs font-semibold">
                    {uploadingCount > 0
                      ? `Uploading ${uploadingCount} item${uploadingCount > 1 ? 's' : ''}`
                      : `${uploads.length} item${uploads.length > 1 ? 's' : ''} uploaded`}
                  </p>
                  <p className="text-[10px] text-blue-200">
                    {uploadingCount > 0 ? `${secondsLeft} second${secondsLeft !== 1 ? 's' : ''} left` : 'Finished'}
                  </p>
                </div>
              </div>

              <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
                <button
                  onClick={() => fileInputRef.current?.click()}
                  className="bg-transparent hover:bg-white/10 text-white border border-white/20 hover:border-white/40 text-xs px-3 py-1.5 rounded-full font-semibold flex items-center gap-1 transition-all cursor-pointer"
                >
                  Add <ChevronDown className="size-3" />
                </button>
                {isMinimized && (
                  <button
                    onClick={() => setIsMinimized(false)}
                    className="text-white/80 hover:text-white p-1 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
                    title="Expand"
                  >
                    <ChevronUp className="size-4" />
                  </button>
                )}
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

### `components/uui/dropbox-upload/example.tsx`

```tsx
'use client';

import { DropboxUpload } from './dropbox-upload';

export function DropboxUploadExample() {
  return <DropboxUpload />;
}

export { DropboxUpload } from './dropbox-upload';
```
