import { UploadIcon } from "lucide-react" ;
import { useState } from "react" ;
import { isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
export default function DropZoneDemo () {
const [ files , setFiles ] = useState < string []>([]);
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
onDrop = { async ( e ) => {
const dropped = e.items
. filter (isFileDropItem)
. map (( item ) => item.name);
setFiles (( f ) => [ ... f, ... dropped]);
}}
>
< UploadIcon aria-hidden className = "size-6 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Drop files here
</ Text >
< span className = "text-muted-foreground text-xs" >or</ span >
< FileTrigger
allowsMultiple
onSelect = {( list ) =>
setFiles (( f ) => [
... f,
... Array. from (list ?? []). map (( file ) => file.name),
])
}
>
< Button variant = "outline" size = "sm" >
Browse files
</ Button >
</ FileTrigger >
</ DropZone >
{files. length > 0 && (
< ul className = "list-inside list-disc text-muted-foreground text-sm" >
{files. map (( name , i ) => (
// biome-ignore lint/suspicious/noArrayIndexKey: same file can be added twice
< li key = { `${ name }-${ i }` }>{name}</ li >
))}
</ ul >
)}
</ div >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/drop-zone
The CLI installs dependencies and any other components this one uses.
import { isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
< DropZone
onDrop = { async ( e ) => {
const files = await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
);
upload (files);
}}
>
< Text slot = "label" >Drop files here</ Text >
< FileTrigger allowsMultiple onSelect = {( list ) => upload (Array. from (list ?? []))}>
< Button variant = "outline" size = "sm" >Browse files</ Button >
</ FileTrigger >
</ DropZone >
Always offer a file picker
Dragging isn't possible on many touch devices and is hard for some users.
Put a FileTrigger button inside (or next to) every drop zone so files can
also be chosen from the system dialog.
DropZone only receives data; it doesn't upload or store anything. onDrop gives you DropItems: use isFileDropItem, isDirectoryDropItem and isTextDropItem from react-aria-components to narrow them, then getFile(), getEntries() or getText(type) to read them.
Drop Zone — uploading files or accepting dragged data into a specific area of the page.
FileTrigger alone — a plain "Upload" button where dragging adds nothing, such as a single attachment in a dense form.
Grid List or Table with useDragAndDrop — reordering or dropping items between positions in a collection.
< DropZone > { /* drop target + visually hidden button */ }
< Icon />
< Text slot = "label" /> { /* names the drop zone for screen readers */ }
< FileTrigger > { /* hidden <input type="file"> */ }
< Button /> { /* opens the system file dialog */ }
</ FileTrigger >
</ DropZone >
Part Renders Notes DropZone<div> + visually hidden <button>Dashed box, centered column. The hidden button receives focus, paste and keyboard drops. Text slot="label"<span>From react-aria-components. Its text becomes the accessible name of the hidden button. FileTriggerhidden <input type="file"> Re-exported from React Aria. Makes its child (any pressable, usually a Button) open the file dialog.
size sets the minimum height and padding: sm for inline attachment areas, md (default) for forms, lg for a page's primary upload target.
import { Text } from "react-aria-components" ;
import { DropZone } from "@/components/ui/drop-zone" ;
export default function DropZoneSizes () {
return (
< div className = "flex w-full max-w-sm flex-col gap-4" >
{([ "sm" , "md" , "lg" ] as const ). map (( size ) => (
< DropZone key = {size} size = {size}>
< Text slot = "label" className = "text-muted-foreground" >
Drop files here ({size})
</ Text >
</ DropZone >
))}
</ div >
);
}
getDropOperation decides whether a drag is accepted while it's still over the zone. Return "cancel" for unsupported types and the zone won't highlight or accept the drop. Match it with acceptedFileTypes on FileTrigger, and still filter in onDrop, since some browsers don't reveal file types until the drop.
import { ImageIcon } from "lucide-react" ;
import { useState } from "react" ;
import { type FileDropItem, isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
const accepted = [ "image/png" , "image/jpeg" , "image/webp" ];
export default function DropZoneAcceptedTypes () {
const [ files , setFiles ] = useState < string []>([]);
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
getDropOperation = {( types ) => (types. has (accepted) ? "copy" : "cancel" )}
onDrop = {( e ) => {
const images = e.items. filter (
( item ) : item is FileDropItem =>
isFileDropItem (item) && accepted. includes (item.type),
);
setFiles (images. map (( item ) => item.name));
}}
>
< ImageIcon aria-hidden className = "size-6 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Drop images here
</ Text >
< span className = "text-muted-foreground text-xs" >PNG, JPEG or WebP</ span >
< FileTrigger
acceptedFileTypes = {accepted}
allowsMultiple
onSelect = {( list ) =>
setFiles (Array. from (list ?? []). map (( file ) => file.name))
}
>
< Button variant = "outline" size = "sm" >
Choose images
</ Button >
</ FileTrigger >
</ DropZone >
{files. length > 0 && (
< p className = "text-muted-foreground text-sm" >{files. join ( ", " )}</ p >
)}
</ div >
);
}
Read each FileDropItem with getFile() and preview it with URL.createObjectURL. Revoke the URLs when they're no longer shown.
import { ImagePlusIcon } from "lucide-react" ;
import { useEffect, useRef, useState } from "react" ;
import { isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
type Preview = { name : string ; url : string };
export default function DropZoneImagePreview () {
const [ previews , setPreviews ] = useState < Preview []>([]);
const urls = useRef < string []>([]);
const add = ( files : File []) => {
const next = files
. filter (( f ) => f.type. startsWith ( "image/" ))
. map (( f ) => ({ name: f.name, url: URL . createObjectURL (f) }));
urls.current. push ( ... next. map (( p ) => p.url));
setPreviews (( p ) => [ ... p, ... next]);
};
// Release the object URLs when the component unmounts.
useEffect (
() => () => {
for ( const url of urls.current) URL . revokeObjectURL (url);
},
[],
);
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
getDropOperation = {( types ) => (types. has ( "image/*" ) ? "copy" : "cancel" )}
onDrop = { async ( e ) => {
const files = await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
);
add (files);
}}
>
< ImagePlusIcon aria-hidden className = "size-6 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Drop photos to preview
</ Text >
< FileTrigger
acceptedFileTypes = {[ "image/*" ]}
allowsMultiple
onSelect = {( list ) => add (Array. from (list ?? []))}
>
< Button variant = "outline" size = "sm" >
Choose photos
</ Button >
</ FileTrigger >
</ DropZone >
{previews. length > 0 && (
< ul className = "grid grid-cols-4 gap-2" >
{previews. map (( p ) => (
< li key = {p.url}>
{ /* biome-ignore lint/performance/noImgElement: local blob preview */ }
< img
src = {p.url}
alt = {p.name}
className = "aspect-square w-full rounded-md border object-cover"
/>
</ li >
))}
</ ul >
)}
</ div >
);
}
children (and className) accept a function of the zone's state. Use isDropTarget to change the message while something is dragged over it.
Drag files over this area 0 items received
import { DownloadIcon, UploadCloudIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Text } from "react-aria-components" ;
import { DropZone } from "@/components/ui/drop-zone" ;
export default function DropZoneRenderProps () {
const [ count , setCount ] = useState ( 0 );
return (
< DropZone
className = "max-w-sm"
onDrop = {( e ) => setCount (( c ) => c + e.items. length )}
>
{({ isDropTarget }) => (
<>
{isDropTarget ? (
< DownloadIcon aria-hidden className = "size-6 animate-bounce" />
) : (
< UploadCloudIcon
aria-hidden
className = "size-6 text-muted-foreground"
/>
)}
< Text slot = "label" className = "font-medium" >
{isDropTarget ? "Release to upload" : "Drag files over this area" }
</ Text >
< span className = "text-muted-foreground text-xs" >
{count} item{count === 1 ? "" : "s" } received
</ span >
</>
)}
</ DropZone >
);
}
Drop zones accept any drag data, not only files. Focus the zone (click it or Tab to it) and paste with Ctrl + V / ⌘ + V : the pasted items arrive through the same onDrop, which is how keyboard users can "drop" without dragging.
Drop or paste text Click here, then press Ctrl+V or ⌘V
import { ClipboardPasteIcon } from "lucide-react" ;
import { useState } from "react" ;
import { isTextDropItem, Text } from "react-aria-components" ;
import { DropZone } from "@/components/ui/drop-zone" ;
export default function DropZoneText () {
const [ text , setText ] = useState < string | null >( null );
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
size = "sm"
getDropOperation = {( types ) =>
types. has ( "text/plain" ) ? "copy" : "cancel"
}
onDrop = { async ( e ) => {
const item = e.items. find (isTextDropItem);
if (item) setText ( await item. getText ( "text/plain" ));
}}
>
< ClipboardPasteIcon
aria-hidden
className = "size-5 text-muted-foreground"
/>
< Text slot = "label" className = "font-medium" >
Drop or paste text
</ Text >
< span className = "text-muted-foreground text-xs" >
Click here, then press Ctrl+V or ⌘V
</ span >
</ DropZone >
{text && (
< blockquote className = "line-clamp-4 border-l-2 pl-3 text-muted-foreground text-sm" >
{text}
</ blockquote >
)}
</ div >
);
}
A dropped folder arrives as a DirectoryDropItem; iterate getEntries() (recursively) to reach its files. On FileTrigger, acceptDirectory lets users pick a folder instead of files.
import { FolderIcon, FolderOpenIcon } from "lucide-react" ;
import { useState } from "react" ;
import {
type DirectoryDropItem,
type FileDropItem,
isDirectoryDropItem,
isFileDropItem,
Text,
} from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
/** Recursively lists every file path inside a dropped folder. */
async function listFiles (
item : DirectoryDropItem | FileDropItem ,
prefix = "" ,
) : Promise < string []> {
if ( isFileDropItem (item)) return [ `${ prefix }${ item . name }` ];
const paths : string [] = [];
for await ( const entry of item. getEntries ()) {
paths. push ( ... ( await listFiles (entry, `${ prefix }${ item . name }/` )));
}
return paths;
}
export default function DropZoneDirectory () {
const [ paths , setPaths ] = useState < string []>([]);
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
onDrop = { async ( e ) => {
const items = e.items. filter (
( item ) => isDirectoryDropItem (item) || isFileDropItem (item),
);
const nested = await Promise . all (
items. map (( item ) => listFiles (item)),
);
setPaths (nested. flat ());
}}
>
< FolderOpenIcon aria-hidden className = "size-6 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Drop a project folder
</ Text >
< FileTrigger
acceptDirectory
onSelect = {( list ) =>
setPaths (
Array. from (list ?? []). map (
( file ) => file.webkitRelativePath || file.name,
),
)
}
>
< Button variant = "outline" size = "sm" >
< FolderIcon /> Choose folder
</ Button >
</ FileTrigger >
</ DropZone >
{paths. length > 0 && (
< ul className = "max-h-40 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-xs" >
{paths. map (( path ) => (
< li key = {path} className = "truncate py-0.5" >
{path}
</ li >
))}
</ ul >
)}
</ div >
);
}
isDisabled stops the zone from accepting drops or paste and dims it. Disable the FileTrigger's button too, and say why.
import { HardDriveIcon } from "lucide-react" ;
import { Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
export default function DropZoneDisabled () {
return (
< DropZone className = "max-w-sm" isDisabled >
< HardDriveIcon aria-hidden className = "size-6 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Storage full
</ Text >
< span className = "text-muted-foreground text-xs" >
You've used 5 GB of 5 GB. Upgrade to upload more files.
</ span >
< FileTrigger >
< Button variant = "outline" size = "sm" isDisabled >
Browse files
</ Button >
</ FileTrigger >
</ DropZone >
);
}
Check size and count yourself in onDrop and onSelect, show errors in an aria-live region, and disable the zone once the limit is reached. Link the rules to the zone with aria-describedby.
Up to 3 files, 2 MB each.
import { FileIcon, UploadIcon } from "lucide-react" ;
import { useState } from "react" ;
import { isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
const MAX_FILES = 3 ;
const MAX_BYTES = 2 * 1024 * 1024 ;
export default function DropZoneValidation () {
const [ files , setFiles ] = useState < File []>([]);
const [ errors , setErrors ] = useState < string []>([]);
const add = ( incoming : File []) => {
const next : string [] = [];
const ok = incoming. filter (( f ) => {
if (f.size > MAX_BYTES ) {
next. push ( `${ f . name } is larger than 2 MB.` );
return false ;
}
return true ;
});
const room = MAX_FILES - files. length ;
if (ok. length > room) {
next. push ( `You can attach up to ${ MAX_FILES } files.` );
}
setFiles (( f ) => [ ... f, ... ok. slice ( 0 , Math. max (room, 0 ))]);
setErrors (next);
};
const isFull = files. length >= MAX_FILES ;
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< DropZone
size = "sm"
isDisabled = {isFull}
aria-describedby = "upload-rules"
className = {errors. length ? "border-destructive" : undefined }
onDrop = { async ( e ) =>
add (
await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
),
)
}
>
< UploadIcon aria-hidden className = "size-5 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
{isFull ? "File limit reached" : "Drop files or" }
</ Text >
< FileTrigger allowsMultiple onSelect = {( l ) => add (Array. from (l ?? []))}>
< Button variant = "outline" size = "sm" isDisabled = {isFull}>
Browse
</ Button >
</ FileTrigger >
</ DropZone >
< p id = "upload-rules" className = "text-muted-foreground text-xs" >
Up to { MAX_FILES } files, 2 MB each.
</ p >
< div aria-live = "polite" >
{errors. map (( error ) => (
< p key = {error} className = "text-destructive text-xs" >
{error}
</ p >
))}
</ div >
{files. length > 0 && (
< ul className = "grid gap-1.5 text-sm" >
{files. map (( f , i ) => (
// biome-ignore lint/suspicious/noArrayIndexKey: files can share a name
< li key = { `${ f . name }-${ i }` } className = "flex items-center gap-2" >
< FileIcon className = "size-4 text-muted-foreground" />
< span className = "flex-1 truncate" >{f.name}</ span >
< span className = "text-muted-foreground text-xs tabular-nums" >
{(f.size / 1024 ). toFixed ( 0 )} KB
</ span >
</ li >
))}
</ ul >
)}
</ div >
);
}
Files held in state aren't part of the form. Append them to the FormData in onSubmit before sending it.
import { PaperclipIcon, XIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Form, isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
import { TextField } from "@/components/ui/text-field" ;
export default function DropZoneForm () {
const [ files , setFiles ] = useState < File []>([]);
const [ result , setResult ] = useState < string | null >( null );
return (
< Form
className = "flex w-full max-w-sm flex-col gap-4"
onSubmit = {( e ) => {
e. preventDefault ();
const data = new FormData (e.currentTarget);
for ( const file of files) data. append ( "attachments" , file);
// await fetch("/api/tickets", { method: "POST", body: data });
setResult (
JSON . stringify ({
subject: data. get ( "subject" ),
attachments: data
. getAll ( "attachments" )
. map (( f ) => (f as File ).name),
}),
);
}}
>
< TextField
label = "Subject"
name = "subject"
defaultValue = "Invoice shows the wrong VAT number"
isRequired
/>
< DropZone
size = "sm"
onDrop = { async ( e ) => {
const dropped = await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
);
setFiles (( f ) => [ ... f, ... dropped]);
}}
>
< Text slot = "label" className = "text-muted-foreground" >
Drop screenshots or logs
</ Text >
< FileTrigger
allowsMultiple
onSelect = {( list ) =>
setFiles (( f ) => [ ... f, ... Array. from (list ?? [])])
}
>
< Button variant = "outline" size = "sm" >
< PaperclipIcon /> Attach files
</ Button >
</ FileTrigger >
</ DropZone >
{files. length > 0 && (
< ul className = "flex flex-wrap gap-2" >
{files. map (( file , i ) => (
< li
// biome-ignore lint/suspicious/noArrayIndexKey: files can share a name
key = { `${ file . name }-${ i }` }
className = "flex h-7 items-center gap-1 rounded-md border bg-muted/40 pr-1 pl-2 text-xs"
>
< span className = "max-w-40 truncate" >{file.name}</ span >
< Button
variant = "ghost"
size = "icon-xs"
aria-label = { `Remove ${ file . name }` }
onPress = {() => setFiles (( f ) => f. filter (( _ , j ) => j !== i))}
>
< XIcon />
</ Button >
</ li >
))}
</ ul >
)}
< Button type = "submit" className = "self-start" >
Send request
</ Button >
{result && (
< code className = "break-all rounded-md bg-muted px-2 py-1 text-xs" >
{result}
</ code >
)}
</ Form >
);
}
A compact drop zone with an inline "browse" link, and a list of files with ProgressBar s and cancel buttons.
import {
CheckCircle2Icon,
FileTextIcon,
UploadIcon,
XIcon,
} from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { isFileDropItem, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
import { ProgressBar } from "@/components/ui/progress-bar" ;
type Upload = { id : string ; name : string ; size : number ; progress : number };
const formatSize = ( bytes : number ) =>
bytes > 1024 * 1024
? `${ ( bytes / 1024 / 1024 ). toFixed ( 1 ) } MB`
: `${ Math . max ( 1 , Math . round ( bytes / 1024 )) } KB` ;
export default function DropZoneRecipeUploadList () {
const [ uploads , setUploads ] = useState < Upload []>([
{ id: "seed" , name: "Q3 board deck.pdf" , size: 4_200_000 , progress: 100 },
]);
// Simulated upload progress. Replace with XHR/fetch progress events.
const isUploading = uploads. some (( u ) => u.progress < 100 );
useEffect (() => {
if ( ! isUploading) return ;
const timer = setInterval (() => {
setUploads (( list ) =>
list. map (( u ) =>
u.progress < 100
? {
... u,
progress: Math. min ( 100 , u.progress + 8 + Math. random () * 12 ),
}
: u,
),
);
}, 300 );
return () => clearInterval (timer);
}, [isUploading]);
const add = ( files : File []) =>
setUploads (( list ) => [
... list,
... files. map (( f ) => ({
id: crypto. randomUUID (),
name: f.name,
size: f.size,
progress: 0 ,
})),
]);
return (
< div className = "flex w-full max-w-md flex-col gap-4 rounded-xl border bg-card p-5" >
< DropZone
size = "sm"
onDrop = { async ( e ) =>
add (
await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
),
)
}
>
< UploadIcon aria-hidden className = "size-5 text-muted-foreground" />
< Text slot = "label" className = "font-medium" >
Drag and drop files, or{ " " }
< FileTrigger
allowsMultiple
onSelect = {( list ) => add (Array. from (list ?? []))}
>
< Button variant = "link" className = "h-auto p-0" >
browse
</ Button >
</ FileTrigger >
</ Text >
</ DropZone >
< ul className = "grid gap-3" >
{uploads. map (( u ) => {
const done = u.progress >= 100 ;
return (
< li key = {u.id} className = "flex items-start gap-3" >
< FileTextIcon className = "mt-0.5 size-5 shrink-0 text-muted-foreground" />
< div className = "flex min-w-0 flex-1 flex-col gap-1.5" >
< div className = "flex items-center gap-2 text-sm" >
< span className = "truncate font-medium" >{u.name}</ span >
< span className = "shrink-0 text-muted-foreground text-xs" >
{ formatSize (u.size)}
</ span >
{done && (
< CheckCircle2Icon
aria-label = "Uploaded"
className = "ml-auto size-4 shrink-0 text-success"
/>
)}
</ div >
{ ! done && (
< ProgressBar
aria-label = { `Uploading ${ u . name }` }
value = {u.progress}
size = "sm"
showValue = { false }
/>
)}
</ div >
< Button
variant = "ghost"
size = "icon-xs"
aria-label = {done ? `Remove ${ u . name }` : `Cancel ${ u . name }` }
onPress = {() =>
setUploads (( list ) => list. filter (( x ) => x.id !== u.id))
}
>
< XIcon />
</ Button >
</ li >
);
})}
</ ul >
</ div >
);
}
A circular drop zone around an Avatar that only accepts images, with upload and remove buttons beside it.
Profile photo
Drop an image on the circle, or upload one. Square images work best.
Upload
import { CameraIcon } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { isFileDropItem } from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
export default function DropZoneRecipeAvatar () {
const [ src , setSrc ] = useState < string | undefined >();
const setFile = ( file : File | undefined ) => {
if ( ! file?.type. startsWith ( "image/" )) return ;
setSrc ( URL . createObjectURL (file));
};
// Revoke the previous object URL whenever it's replaced or on unmount.
useEffect (
() => () => {
if (src) URL . revokeObjectURL (src);
},
[src],
);
return (
< div className = "flex w-full max-w-md items-center gap-5 rounded-xl border bg-card p-5" >
< DropZone
aria-label = "Profile photo"
getDropOperation = {( types ) => (types. has ( "image/*" ) ? "copy" : "cancel" )}
onDrop = { async ( e ) => {
const item = e.items. find (isFileDropItem);
setFile ( await item?. getFile ());
}}
className = "relative size-20 min-h-0 shrink-0 rounded-full p-0 data-drop-target:ring-4 data-drop-target:ring-brand/20"
>
< Avatar
src = {src}
alt = {src ? "Your profile photo" : "No profile photo" }
fallback = {< CameraIcon className = "size-6 text-muted-foreground" />}
className = "size-full"
/>
</ DropZone >
< div className = "flex flex-col gap-2" >
< div >
< p className = "font-medium text-sm" >Profile photo</ p >
< p className = "text-muted-foreground text-xs" >
Drop an image on the circle, or upload one. Square images work best.
</ p >
</ div >
< div className = "flex gap-2" >
< FileTrigger
acceptedFileTypes = {[ "image/png" , "image/jpeg" , "image/webp" ]}
onSelect = {( list ) => setFile (list?.[ 0 ])}
>
< Button variant = "outline" size = "sm" >
Upload
</ Button >
</ FileTrigger >
{src && (
< Button variant = "ghost" size = "sm" onPress = {() => setSrc ( undefined )}>
Remove
</ Button >
)}
</ div >
</ div >
</ div >
);
}
The whole composer is the drop target: a borderless Textarea , attachment chips and a toolbar. Clicking the textarea still focuses it, because the zone only moves focus to its hidden button when you click a non-focusable area.
import { ImageIcon, PaperclipIcon, SendIcon, XIcon } from "lucide-react" ;
import { useState } from "react" ;
import { isFileDropItem } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { DropZone, FileTrigger } from "@/components/ui/drop-zone" ;
import { Textarea } from "@/components/ui/textarea" ;
export default function DropZoneRecipeComposer () {
const [ files , setFiles ] = useState < File []>([]);
return (
< DropZone
aria-label = "Attach files to comment"
onDrop = { async ( e ) => {
const dropped = await Promise . all (
e.items. filter (isFileDropItem). map (( item ) => item. getFile ()),
);
setFiles (( f ) => [ ... f, ... dropped]);
}}
className = "min-h-0 max-w-md items-stretch gap-0 border-solid bg-card p-0 text-left focus-within:border-ring data-drop-target:border-dashed"
>
{({ isDropTarget }) => (
<>
< Textarea
aria-label = "Comment"
placeholder = {
isDropTarget
? "Drop to attach…"
: "Leave a comment. Drag files here to attach them."
}
rows = { 3 }
resize = "none"
className = "border-0 bg-transparent shadow-none data-focused:ring-0 dark:bg-transparent"
/>
{files. length > 0 && (
< ul className = "flex flex-wrap gap-1.5 px-3 pb-2" >
{files. map (( file , i ) => (
< li
// biome-ignore lint/suspicious/noArrayIndexKey: files can share a name
key = { `${ file . name }-${ i }` }
className = "flex h-6 items-center gap-1 rounded-md bg-muted pr-0.5 pl-2 text-xs"
>
< ImageIcon className = "size-3 text-muted-foreground" />
< span className = "max-w-32 truncate" >{file.name}</ span >
< Button
variant = "ghost"
size = "icon-xs"
className = "size-5"
aria-label = { `Remove ${ file . name }` }
onPress = {() => setFiles (( f ) => f. filter (( _ , j ) => j !== i))}
>
< XIcon />
</ Button >
</ li >
))}
</ ul >
)}
< div className = "flex items-center justify-between border-t px-2 py-1.5" >
< FileTrigger
allowsMultiple
onSelect = {( list ) =>
setFiles (( f ) => [ ... f, ... Array. from (list ?? [])])
}
>
< Button variant = "ghost" size = "icon-sm" aria-label = "Attach files" >
< PaperclipIcon />
</ Button >
</ FileTrigger >
< Button size = "sm" >
< SendIcon /> Comment
</ Button >
</ div >
</>
)}
</ DropZone >
);
}
DropZone renders a visually hidden <button> inside the drop area. Clicking a non-interactive part of the zone focuses it, and Tab reaches it like any button.
The hidden button is named by a Text slot="label" inside the zone (plus aria-labelledby, if passed), or by aria-label. Without either, it falls back to a generic "DropZone" label, so always provide one.
Keyboard and screen-reader users can paste into the focused zone; pasted items are delivered to onDrop with the "copy" operation.
During a keyboard or screen-reader drag started from a React Aria draggable (for example a GridList using useDragAndDrop), drop zones become targets that can be reached with Tab and dropped on with Enter .
FileTrigger makes its child open the native file dialog, which is fully accessible. Always include one.
Announce results (files added, errors) in an aria-live region so users know the drop worked.
Key Action Tab Moves focus to the drop zone's hidden button, then to the FileTrigger button Ctrl + V / ⌘ + V Pastes clipboard contents into the focused zone Enter During a keyboard drag, drops onto the focused zone Space / Enter on the buttonOpens the file dialog
Attribute Present when data-drop-targetSomething droppable is dragged over the zone (and getDropOperation didn't return "cancel") data-hoveredHovered with a mouse or pen data-focused / data-focus-visibleThe hidden button is focused / focused with the keyboard data-disabledisDisabled is true
By default the drop-target state switches to a solid brand-colored border on an accent background, and keyboard focus draws a ring.
data-slotElement drop-zoneRoot
className is merged with the defaults, so you can reshape the zone entirely: min-h-0 p-0 removes the size padding, items-stretch text-left undoes the centered column, and rounded-full makes a circular target. See the avatar and composer recipes.
className and children accept a function of the zone state:
< DropZone className = {({ isDropTarget }) => (isDropTarget ? "scale-[1.01]" : "" )}>
{({ isDropTarget }) => (
< Text slot = "label" >{isDropTarget ? "Release to upload" : "Drop files here" }</ Text >
)}
</ DropZone >
Also accepts every prop of React Aria's DropZone .
FileTrigger is re-exported unchanged from React Aria. See its props .