File Dropzone
When a file is dragged in, the dashed border starts to flow and the icon bobs; on drop each file slides into a list with its own progress bar. Click or keyboard opens the picker too. Made for ticket attachments, profile uploads and media libraries.
- React · Motion
- MIT
- shadcnblocks/kibo
- Remix this
Loading preview
Tune
Highlighted lines hold your current values; copies and downloads include them
// SPDX-License-Identifier: MIT// Copyright (c) 2023 — Present shadcnblocks// Source: https://github.com/shadcnblocks/kibo/blob/3d63cdb/packages/dropzone/index.tsx// Modified by Motif; see the item's provenance.import { cn } from '@motif/runtime'import { AnimatePresence, motion, useReducedMotion } from 'motion/react'import { useEffect, useId, useRef, useState, type CSSProperties, type DragEvent, type KeyboardEvent } from 'react'/* @motif:defaults */export const defaults = { variant: 'dashed', color: '#38bdf8', radius: 18, height: 156, spring: { visualDuration: 0.34, bounce: 0.25, }, title: 'Drop files here or', hint: 'PNG, JPG or PDF up to 10 MB',}/* @motif:end */export type DropzoneFile = { id: string name: string /** 字节数。 */ size: number /** 0 到 1;到 1 表示上传完成。 */ progress: number}export type InputDropzoneProps = Partial<typeof defaults> & { files?: DropzoneFile[] onFilesChange?: (files: DropzoneFile[]) => void /** 收到原始 File 对象(真实上传交给你)。 */ onDrop?: (files: File[]) => void /** 强制显示拖入状态(演示自动播放用)。 */ dragActive?: boolean accept?: string maxFiles?: number disabled?: boolean className?: string style?: CSSProperties}const formatSize = (bytes: number) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`)const extensionOf = (name: string) => (name.includes('.') ? name.split('.').pop()!.slice(0, 4).toUpperCase() : 'FILE')function matchesAccept(file: File, accept?: string) { if (!accept) return true return accept.split(',').some((rule) => { const r = rule.trim().toLowerCase() if (r.startsWith('.')) return file.name.toLowerCase().endsWith(r) if (r.endsWith('/*')) return file.type.startsWith(r.slice(0, -1)) return file.type === r })}/** 拖放上传区:拖入时边框流动、图标弹起;点击或 Enter / Space 打开文件选择;下方是带进度条的文件列表。 */export function InputDropzone({ files, onFilesChange, onDrop, dragActive, accept, maxFiles = 5, disabled, className, style, ...props }: InputDropzoneProps) { const { variant, color, radius, height, spring, title, hint } = { ...defaults, ...props } const reduced = useReducedMotion() const uid = useId() const inputRef = useRef<HTMLInputElement>(null) const zoneRef = useRef<HTMLDivElement>(null) const depth = useRef(0) const [over, setOver] = useState(false) const [inner, setInner] = useState<DropzoneFile[]>([]) const [box, setBox] = useState({ w: 0, h: 0 }) const list = files ?? inner const active = (dragActive ?? over) && !disabled useEffect(() => { const el = zoneRef.current if (!el) return const measure = () => setBox({ w: el.offsetWidth, h: el.offsetHeight }) measure() const observer = new ResizeObserver(measure) observer.observe(el) return () => observer.disconnect() }, []) // 非受控时模拟上传进度;真实项目里由你更新 files。 const pending = files === undefined && inner.some((file) => file.progress < 1) useEffect(() => { if (!pending) return let frame = 0 let last: number | null = null const tick = (now: number) => { const dt = last === null ? 0 : Math.min((now - last) / 1000, 0.1) last = now setInner((prev) => prev.map((file) => (file.progress < 1 ? { ...file, progress: Math.min(1, file.progress + dt * (0.35 + (file.size % 7) * 0.05)) } : file))) frame = requestAnimationFrame(tick) } frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) }, [pending]) const commit = (next: DropzoneFile[]) => { if (files === undefined) setInner(next) onFilesChange?.(next) } const accepted = (incoming: File[]) => { const okay = incoming.filter((file) => matchesAccept(file, accept)) if (okay.length === 0) return onDrop?.(okay) const room = Math.max(0, maxFiles - list.length) commit([...list, ...okay.slice(0, room).map((file, i) => ({ id: `${uid}-${Date.now()}-${i}`, name: file.name, size: file.size, progress: 0 }))]) } const stop = (event: DragEvent) => { event.preventDefault() event.stopPropagation() } const transition = reduced ? { duration: 0 } : { type: 'spring' as const, visualDuration: spring.visualDuration, bounce: spring.bounce } const open = () => !disabled && inputRef.current?.click() return ( <div className={cn('w-full', className)} style={{ '--c': color, ...style } as CSSProperties}> <motion.div ref={zoneRef} role="button" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} aria-label="Upload files" aria-describedby={`${uid}-hint`} onClick={open} onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() open() } }} onDragEnter={(event) => { stop(event) depth.current += 1 setOver(true) }} onDragOver={stop} onDragLeave={(event) => { stop(event) depth.current = Math.max(0, depth.current - 1) if (depth.current === 0) setOver(false) }} onDrop={(event) => { stop(event) depth.current = 0 setOver(false) accepted(Array.from(event.dataTransfer.files)) }} className={cn( 'relative flex cursor-pointer flex-col items-center justify-center gap-3 px-6 text-center outline-none select-none', 'transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--c)', variant === 'soft' && 'border border-border/80', disabled && 'cursor-not-allowed opacity-50', )} style={{ height, borderRadius: radius, backgroundColor: active ? 'color-mix(in oklab, var(--c) 10%, transparent)' : variant === 'soft' ? 'color-mix(in oklab, var(--foreground) 4%, transparent)' : 'transparent', }} animate={{ scale: active && !reduced ? 1.015 : 1 }} transition={transition} > {variant === 'dashed' && box.w > 0 && ( <svg aria-hidden className="pointer-events-none absolute inset-0" width={box.w} height={box.h}> <motion.rect x="1" y="1" width={box.w - 2} height={box.h - 2} rx={radius} fill="none" strokeWidth="1.5" strokeLinecap="round" strokeDasharray="7 7" animate={{ strokeDashoffset: active && !reduced ? [0, -28] : 0 }} transition={active && !reduced ? { duration: 1.1, ease: 'linear', repeat: Infinity } : { duration: 0.2 }} style={{ stroke: active ? color : 'color-mix(in oklab, var(--foreground) 22%, transparent)', transition: 'stroke 0.2s' }} /> </svg> )} <motion.div aria-hidden className="grid size-12 place-items-center rounded-2xl" style={{ backgroundColor: 'color-mix(in oklab, var(--c) 16%, transparent)', color }} animate={active && !reduced ? { y: [0, -6, 0], scale: 1.08 } : { y: 0, scale: 1 }} transition={active && !reduced ? { y: { duration: 0.9, repeat: Infinity, ease: 'easeInOut' }, scale: transition } : transition} > <svg viewBox="0 0 24 24" className="size-6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <path d="M7.5 18.5a4.5 4.5 0 0 1-.6-8.96 5.5 5.5 0 0 1 10.6-.9A4.8 4.8 0 0 1 17 18.5" /> <path d="M12 20.5v-8m0 0-3 3m3-3 3 3" /> </svg> </motion.div> <div> <div className="relative h-5 text-sm font-medium text-foreground"> <AnimatePresence mode="popLayout" initial={false}> <motion.div key={active ? 'active' : 'idle'} initial={{ opacity: 0, y: reduced ? 0 : 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduced ? 0 : -6 }} transition={{ duration: 0.18 }} > {active ? ( 'Release to upload' ) : ( <> {title} <span className="text-(--c) underline decoration-[color-mix(in_oklab,var(--c)_50%,transparent)] underline-offset-[3px]">browse</span> </> )} </motion.div> </AnimatePresence> </div> <div id={`${uid}-hint`} className="mt-1 text-xs text-muted-foreground">{hint}</div> </div> <input ref={inputRef} type="file" multiple accept={accept} tabIndex={-1} aria-hidden className="sr-only" onChange={(event) => { accepted(Array.from(event.target.files ?? [])); event.target.value = '' }} /> </motion.div> <ul aria-label="Uploaded files" className="mt-3 space-y-2 empty:hidden"> <AnimatePresence initial={false}> {list.map((file) => { const done = file.progress >= 1 return ( <motion.li key={file.id} layout={!reduced} initial={{ opacity: 0, y: reduced ? 0 : 10, scale: reduced ? 1 : 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: reduced ? 1 : 0.96 }} transition={transition} className="flex items-center gap-3 rounded-xl border bg-card/60 py-2.5 pr-2.5 pl-3" style={{ borderRadius: Math.max(radius - 6, 8) }} > <span className="grid size-9 shrink-0 place-items-center rounded-lg text-[10px] font-bold tracking-wide" style={{ backgroundColor: 'color-mix(in oklab, var(--c) 16%, transparent)', color }}> {extensionOf(file.name)} </span> <div className="min-w-0 flex-1"> <div className="flex items-baseline justify-between gap-3"> <span className="truncate text-[13px] font-medium">{file.name}</span> <span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">{done ? formatSize(file.size) : `${Math.round(file.progress * 100)}%`}</span> </div> <div role="progressbar" aria-label={`Uploading ${file.name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(file.progress * 100)} className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/10"> <div className="h-full origin-left rounded-full" style={{ transform: `scaleX(${file.progress})`, backgroundColor: done ? '#34d399' : color }} /> </div> </div> <button type="button" aria-label={`Remove ${file.name}`} onClick={() => commit(list.filter((f) => f.id !== file.id))} className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors duration-150 outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-(--c)" > {done ? ( <svg viewBox="0 0 16 16" className="size-4 text-emerald-400" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg> ) : ( <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden><path d="m4 4 8 8M12 4l-8 8" /></svg> )} </button> </motion.li> ) })} </AnimatePresence> </ul> </div> )}

