
An asset-upload card with a drag-and-drop dropzone and a progress-tracked upload queue.
@quill
registry/blocks/file-upload.tsx
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Icon } from '@/components/ui/icon'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
const files = [
{ name: 'brand-lockups.zip', size: '4.2 MB', progress: 100, state: 'done' },
{ name: 'declaration.webp', size: '189 KB', progress: 100, state: 'done' },
{ name: 'field-notes-issue-001.pdf', size: '2.8 MB', progress: 64, state: 'uploading' },
] as const
/** An asset-upload card with a drag-and-drop dropzone and a progress-tracked upload queue. */
export function FileUpload() {
return (
<Card className="w-[440px]">
<CardHeader>
<CardTitle>Upload assets</CardTitle>
<CardDescription>SVG, PNG, PDF or ZIP — up to 25 MB each.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<button
type="button"
className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-input bg-transparent px-6 py-10 text-center transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<Icon name="cloud_upload" size={28} className="text-muted-foreground" aria-hidden />
<span className="text-sm font-medium text-foreground">
Drag files here or click to browse
</span>
<span className="text-xs text-muted-foreground">Your files stay private.</span>
</button>
<Separator />
<ul className="flex flex-col gap-3" aria-label="Upload queue">
{files.map((f) => (
<li key={f.name} className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon name={f.state === 'done' ? 'check' : 'draft'} size={18} aria-hidden />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-foreground">{f.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{f.size}</span>
</div>
{f.state === 'uploading' && (
<Progress value={f.progress} aria-label={`Uploading ${f.name}`} />
)}
</div>
<Button variant="ghost" size="icon-sm" aria-label={`Remove ${f.name}`}>
<Icon name="close" size={16} />
</Button>
</li>
))}
</ul>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="ghost">Cancel</Button>
<Button>Done</Button>
</CardFooter>
</Card>
)
}
An asset-upload card with a drag-and-drop dropzone and a progress-tracked upload queue. Do: Make the dropzone a real, focusable <button> that also opens a file picker on click/Enter — never make drag-and-drop the only way in. Don't: Ship a dropzone that only responds to a pointer drag gesture. Do: Give each in-progress file its own progress bar, so users can see exactly how far each individual upload has gotten. Don't: Show one shared progress bar for the whole queue — users can't tell which file it belongs to or how far any single file has gotten. Use contact-form when you need a single attachment inside a broader form, not a dedicated upload queue as the whole card.




























