Media
Files, pictures and sound: uploads, libraries, editors, players, arrangers and product pictures.
Installation & usage
npx @youj/cli add media-dropzoneAlso installs alert, button, collapse, icon-check, icon-file, icon-file-archive, icon-file-audio, icon-file-image, icon-file-text, icon-file-video, icon-folder-open, icon-play, icon-refresh, icon-x, presence-list, progress, spinner, use-announcer, youj-core, youj-presets, youj-ui-core.
The CLI copies the block to blocks/media/dropzone.tsx with every component it uses, so it’s yours to edit.
Every prop is optional and falls back to the demo, so it renders as you see it until you pass your own. Give it your data, rewrite or translate its strings through copy, and connect its actions:
'use client'
import { MediaDropzone } from '@/blocks/media/dropzone'
export function MediaDropzoneView() {
return (
<MediaDropzone
copy={{ title: 'Upload files' }}
onFilesChange={(files) => {
// your code
}}
onUpload={async (file, context) => {
// await your API here; the block shows progress until it settles
}}
onRemove={async (file) => {
// await your API here; the block shows progress until it settles
}}
/>
)
}Description
A drop zone that takes files every way people have them. It wakes as soon as files are dragged anywhere over the page, says how many are coming and whether they're accepted, and turns to danger when none would be; a drop that misses it never opens the file in the tab. Folders drop or pick whole, read in batches with their paths kept and system files left out, and ⌘V pastes a screenshot under the moment's name. Every refusal has its own reason: the type, the size, the count, the total, a duplicate, an image's dimensions or your own check. Files show a real thumbnail drawn small, a video's frame and running time, then upload three at a time with progress and Retry.
Uploads that keep going while people keep working. Wrap a page in it and files dropped anywhere upload under a veil that names where they go; any button inside sends files with useUploadTray().add. A few move at once, each pauses and resumes from the bytes already stored (your onUpload gets the offset and an AbortSignal, as tus and S3 multipart need), and a failure retries itself with a countdown. Going offline holds everything and says so, and coming back resumes it. The header sums it up with a smoothed speed and an honest time left, folds to a hairline of progress, and asks before closing on unfinished uploads.
An image field that shows exactly how the picture will look everywhere it appears. The photo moves under a fixed frame: drag it, pinch or scroll to zoom about the pointer, turn it a quarter, straighten it by the degree or flip it, and at any angle it still covers the frame. The focal point marks what matters, and the previews beside it are the real framing at each size the image is shown, cropped around the focal point as far as the edges allow. Save draws the result on a canvas and hands over the file with its region in the source's pixels and the focal point, ready for an image CDN. It also comes as a 3:1 cover and a round avatar.
A media picker that's as quick from the keyboard as with a mouse. Search names and descriptions, narrow by type, open a folder, switch between grid and list. Picking keeps its order, the first being the cover, and stops at a maximum with a word on why. Arrows move through the grid in two dimensions, Shift picks a run and ⌘A picks everything shown. Enter opens a lightbox that grows from its tile and picks from there. Drop files on the grid to upload into the open folder; they fill as they go and are picked as they land.
A reply box that takes files while you write: attach, paste a screenshot or drop files, and each uploads at once with a ring as it goes. Arrange them by dragging, with a caret showing where one lands, or with Alt or Option and the arrows. Send waits for uploads still running and says how many it's waiting for, then sends the text and the files in their order; a failed upload stops it with focus on the file, which retries on a press.
A project in tracks: a song idea, a podcast edit or a video timeline. Drag clips to move or trim them with snapping, solo or mute each track, and open the selected clip in an inspector with its start, end and gain. Play runs the clock and playhead.
An editorial storefront card where the photo is the card: a 4:5 studio shot with the name, price, category and rating under it. Hover or focus turns the product to its other side (or eases the shot closer), the swatches preview and pick a colorway with its own photo, and quick add goes straight to the cart or opens sizes first. Use it for a shop grid; pass your own photos per product or per colorway.
A spec card built around an exploded drawing of one device. Switch between assembled and exploded, and point at a spec figure to light the part it describes. The drawing is a fixed five-layer illustration; the part names and the spec sheet are your data. Use it for hardware product pages.
Partly demo. copy, parts, specs and onOrder are props; the five-layer drawing itself is fixed.
Code
Dropzone, the Drop zone story: the files youj add writes.
blocks/media
Reference
Props22 props, 3 types
Props
| Prop | Type | |
|---|---|---|
theme | ThemeInputWraps the block in this theme. Default: the theme around it. | – |
embedded | booleanA panel inside another page: an default true | true |
fill | booleanTake the whole width it's given instead of stopping at its natural width (672px at 100% scale). default false | false |
copy | Partial<MediaDropzoneCopy>Any of the block's strings, to rewrite or translate. | – |
locale | stringWrites sizes and running times in this locale (BCP 47). Default: | – |
defaultFiles | MediaDropzoneFile[]The files it starts with, usually the ones already stored ( default four demo files part-way through | four demo files part-way through |
onFilesChange | (files: MediaDropzoneFile[]) => voidEvery change to the list: added, uploading, uploaded (with what | – |
accept | stringThe types it takes, as an input's default 'image/jpeg,image/png,image/webp,video/mp4,video/quicktime,application/pdf' | 'image/jpeg,image/png,image/webp,video/mp4,video/quicktime,application/pdf' |
maxSize | numberThe largest file, in bytes. default 50 MB | 50 MB |
minSize | numberThe smallest file, in bytes. default 1 | 1 |
maxFiles | numberHow many files it holds. default 20 | 20 |
maxTotalSize | numberAll the files together, in bytes. default 200 MB | 200 MB |
minDimensions | { width: number; height: number }An image's smallest size in pixels: | – |
validate | (file: File) => string | null | undefined | Promise<string | null | undefined>Your own rule: return a reason to refuse the file, or nothing to take it. May be async (a name check on your server). | – |
folders | booleanTake whole folders, dropped or picked. default true | true |
paste | booleanTake files pasted into the block, or onto the page while nothing else has focus. default true | true |
disabled | booleandefault false | false |
onUpload | (file: File, context: MediaDropzoneUploadContext) => Promise<unknown> | unknownUploads one file: report progress (0–100) and resolve when it's stored, with anything you'll want back (a URL, a key); reject to show why. Default: a simulated upload. | – |
onRemove | (file: MediaDropzoneFile) => Promise<unknown> | unknownRemoves a stored file (from your storage). Awaited, the row dimmed meanwhile; reject to keep it and say why. Without it, removing only takes it off the list. | – |
onReject | (rejections: MediaDropzoneRejection[]) => voidThe files the rules turned away, each with its reason. | – |
footer | ReactNodeUnder the list, beside the totals: a Continue button, a note. | – |
classNames | { list?: string }Classes for its parts: | – |
MediaDropzoneFile
| Prop | Type | |
|---|---|---|
idRequired | string | – |
nameRequired | string | – |
sizeRequired | numberIn bytes. | – |
typeRequired | stringMIME type. | – |
path | stringThe folder it came from, inside a dropped or picked folder: | – |
preview | stringA small picture of it: a thumbnail for an image, a frame for a video. Made from the file when it's added. | – |
width | numberIn pixels, for an image or a video. | – |
height | number | – |
duration | numberIn seconds, for a video or an audio file. | – |
statusRequired | MediaDropzoneStatus'checking''queued''uploading''done''error''removing'
| – |
progress | number | null0–100 while uploading; | – |
error | stringWhy it failed. | – |
file | FileThe file itself (absent on files that were already stored). | – |
result | unknownWhat | – |
MediaDropzoneRejection
| Prop | Type | |
|---|---|---|
nameRequired | string | – |
reasonRequired | string | – |
MediaDropzoneUploadContext
| Prop | Type | |
|---|---|---|
signalRequired | AbortSignalAborted when the file is removed or the block unmounts. | – |
onProgressRequired | (percent: number) => voidReport progress, 0–100. | – |
path | stringIts folder inside a dropped folder, to keep the structure on your side: | – |
Strings56 keys for copy
Strings
copy takes any of these keys; the rest keep their defaults. A key whose text holds a value (a count, a name) takes a function.
titledescriptiondropbrowsepasteHintdropReadydropCountdropSomedropNoneonlyTypesdropReleasereadingfullfullHintchooseFoldertypestypeGroupatLeasteachUpToinAllrefusedTitlerefusedMoredismissskippednotAcceptedtooLargeemptyduplicatetooManyoverBudgettooSmallunreadablepastedNamefilesnoFilescheckingqueueduploadedfaileduploadFailedremovingremoveFailedretryremovecanceluploadingdimensionssummaryofBudgetbudgetaddedrefuseduploadedOnefailedOneremovedlocaleConnecting your storageonUpload, progress, resume, abort
Connecting your storage
Nothing here touches the network. Every upload goes through an onUpload you wire to your own storage, and without one a simulated upload stands in (it warns once in development). Each block awaits it, shows its progress, stops it through the AbortSignal when a file is removed or paused, and keeps whatever it resolves with (a URL, a key) on the file.
The drop zone, the media library and the attachments report progress in percent. With a presigned URL (S3, R2, Vercel Blob), an XMLHttpRequest reports it as the bytes go:
async function onUpload(file: File, { signal, onProgress }: { signal: AbortSignal; onProgress: (percent: number) => void }) {
const { url, key } = await fetch('/api/uploads', { method: 'POST', body: JSON.stringify({ name: file.name, type: file.type }) }).then((r) => r.json())
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => onProgress((event.loaded / event.total) * 100)
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Upload failed (${xhr.status})`)))
xhr.onerror = () => reject(new Error('The connection dropped.'))
xhr.onabort = () => reject(new Error('Canceled'))
signal.addEventListener('abort', () => xhr.abort())
xhr.open('PUT', url)
xhr.send(file)
})
return { key }
}The upload tray reports bytes and passes the offset already stored, so a resumable protocol carries on from there after a pause or a lost connection: tus picks it up from its upload URL, S3 multipart from its last part. An uploader that can't resume starts over and reports from 0, and the tray follows. Rejecting retries after each of retryDelays, then shows your message with Retry.
Accessibilitydrop targets, progress, sliders, motion
Accessibility
Every drop target is also a button, or sits beside one, so nothing needs a mouse: the drop zone is the picker's button and describes its rules, the image editor's empty stage is a button, and the library and the composer have Upload and Attach. Pasting works wherever focus is in the block. Refusals are listed with one reason per file, tied to the zone or the field with aria-describedby; outcomes (added, uploaded, failed, all done, removed, moved) are announced politely through one live region per block, and failures assertively. Progress isn't read out percent by percent: each file is a named progress bar with its bytes and time left as its value text.
The upload tray's header is a disclosure button whose name is its summary; each row's ring is its Pause, Resume or Retry, named for the file, and closing mid-upload is an alert dialog. The image editor's stage is a labelled group that describes its keys (arrows move, + and − zoom, [ and ] straighten, R turns, H and V flip, 0 resets); the focal point is a control of its own whose position is read out, and the sliders speak in degrees and percent. The media library is one multi-select listbox with a single tab stop: arrows move in two dimensions, Space picks, Shift picks a run, ⌘A or Ctrl+A picks all and Escape clears, and each option's name includes its place in the selection. Its lightbox is a dialog that steps with the arrow keys and hands focus back to the file's tile. The composer's attachments are buttons named with their size and state; Alt or Option with an arrow moves one and says where it went, Delete removes it and focus moves on, and ⌘↵ or Ctrl+↵ sends.
The Chapter player's waveform is a slider underneath: the arrow keys step, Page Up and Page Down step ten times as far, and the chapter title is announced politely as it changes. The play button is named and carries aria-pressed, the skip buttons say how far they jump, the speed fader is labelled, the clip toggle is pressed while a clip is open, and the chapter now playing is marked aria-current.
Both players are simulated by a clock; wire them to real audio and keep these labels. Under reduced motion the wave is drawn in place and level strips jump to each reading.
In Track arranger every clip is a tab stop: the arrow keys move it, Shift moves it ten times as far, and its trim edges are sliders. Play and rewind are named, and the clock is role="timer" with live announcements off. The gain fader is labelled.
The product card is an article named by its product name (an h2), and its photos are decorative (alt=""); a studio shot becomes an image only when you pass label, and its fallback keeps that name if the photo fails. The colorway swatches are a radio group: each is named by its color, and the arrow keys pick one. Quick add is a button named for the product, and a product with sizes opens a labelled group over the photo Escape closes it and focus returns. A struck-through price is read as was and now.
The device spec card keeps its drawing aria-hidden; the Assembled / Exploded switch and the spec figures are labelled groups, and focusing a figure lights its part the same way hovering does.
Under reduced motion the turn, the push-in, the photo fades and crossfades, the sheet and every cart change happen at once.
Under reduced motion the zone and tray change in place, rows and chips appear and leave at once, progress jumps, the editor's changes land without gliding or springing back, and the lightbox opens without growing. Colors and messages still show every state.