Skip to content

Media

Files, pictures and sound: uploads, libraries, editors, players, arrangers and product pictures.

Installation & usage

npx @youj/cli add media-dropzone

Also 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:

mediaDropzone-view.tsx
'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
      }}
    />
  )
}

Reference

Props22 props, 3 types

Props

MediaDropzone props
PropType
themeThemeInput

Wraps the block in this theme. Default: the theme around it.

–
embeddedboolean

A panel inside another page: an h2, and no page around it.

default true

true
fillboolean

Take the whole width it's given instead of stopping at its natural width (672px at 100% scale).

default false

false
copyPartial<MediaDropzoneCopy>

Any of the block's strings, to rewrite or translate.

–
localestring

Writes sizes and running times in this locale (BCP 47). Default: copy.locale, else the app's locale (YoujProvider), else en-US.

–
defaultFilesMediaDropzoneFile[]

The files it starts with, usually the ones already stored (status: 'done').

default four demo files part-way through

four demo files part-way through
onFilesChange(files: MediaDropzoneFile[]) => void

Every change to the list: added, uploading, uploaded (with what onUpload resolved), failed, removed.

–
acceptstring

The types it takes, as an input's accept: extensions (.pdf), MIME types (image/png) or families (image/*).

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'
maxSizenumber

The largest file, in bytes.

default 50 MB

50 MB
minSizenumber

The smallest file, in bytes.

default 1

1
maxFilesnumber

How many files it holds.

default 20

20
maxTotalSizenumber

All the files together, in bytes.

default 200 MB

200 MB
minDimensions{ width: number; height: number }

An image's smallest size in pixels: { width: 1200, height: 1200 } for product photos. Default: any.

–
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).

–
foldersboolean

Take whole folders, dropped or picked.

default true

true
pasteboolean

Take files pasted into the block, or onto the page while nothing else has focus.

default true

true
disabledboolean

default false

false
onUpload(file: File, context: MediaDropzoneUploadContext) => Promise<unknown> | unknown

Uploads 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> | unknown

Removes 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[]) => void

The files the rules turned away, each with its reason.

–
footerReactNode

Under the list, beside the totals: a Continue button, a note.

–
classNames{ list?: string }

Classes for its parts: list is the region the files scroll in (its height, 256px at 100%, holds the card's size).

–

MediaDropzoneFile

MediaDropzoneFile props
PropType
idRequiredstring–
nameRequiredstring–
sizeRequirednumber

In bytes.

–
typeRequiredstring

MIME type.

–
pathstring

The folder it came from, inside a dropped or picked folder: lookbook/day-2.

–
previewstring

A small picture of it: a thumbnail for an image, a frame for a video. Made from the file when it's added.

–
widthnumber

In pixels, for an image or a video.

–
heightnumber–
durationnumber

In seconds, for a video or an audio file.

–
statusRequiredMediaDropzoneStatus
'checking''queued''uploading''done''error''removing'

done for a file that's already stored.

–
progressnumber | null

0–100 while uploading; null before the first report.

–
errorstring

Why it failed.

–
fileFile

The file itself (absent on files that were already stored).

–
resultunknown

What onUpload resolved with: a URL, a key.

–

MediaDropzoneRejection

MediaDropzoneRejection props
PropType
nameRequiredstring–
reasonRequiredstring–

MediaDropzoneUploadContext

MediaDropzoneUploadContext props
PropType
signalRequiredAbortSignal

Aborted when the file is removed or the block unmounts.

–
onProgressRequired(percent: number) => void

Report progress, 0–100.

–
pathstring

Its folder inside a dropped folder, to keep the structure on your side: lookbook/day-2.

–
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.

titledescriptiondropbrowsepasteHintdropReadydropCountdropSomedropNoneonlyTypesdropReleasereadingfullfullHintchooseFoldertypestypeGroupatLeasteachUpToinAllrefusedTitlerefusedMoredismissskippednotAcceptedtooLargeemptyduplicatetooManyoverBudgettooSmallunreadablepastedNamefilesnoFilescheckingqueueduploadedfaileduploadFailedremovingremoveFailedretryremovecanceluploadingdimensionssummaryofBudgetbudgetaddedrefuseduploadedOnefailedOneremovedlocale
Built from6 components
Connecting 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.