ImageUploader
ImplementedImage picker with currentSrc preview (the persisted image IS the control — click to replace), variant=avatar (96px circular slot with initials fallback), and an optional inline remove control. Absorbs the removed AvatarUploader and FileDropzone molecule surfaces (D1 dedup merge).
patterns/molecules/imageuploader.htmlImage picker with currentSrc preview (persisted image IS the control — click to replace), variant=avatar (96px circular slot with initials fallback), and optional inline remove control. Composes Caption and IconButton.
ImageUploader
Interactive molecule (Family 1 — Tier 1)
Click-to-pick image field. Hidden input + visible button trigger; the saved image becomes the control (click to replace). Avatar variant: fixed 96px circle with initials fallback. No drag-drop — FileUpload organism owns that.
400×400 recommended, max 2MB
1600×643 recommended
File too large (max 2MB)
Pick up to 10 images
This component supports light and dark mode.
Interactive demo — upload lifecycle
The controlled demo below shows the full lifecycle: empty dropzone → picked files (chips) → "Save upload" (simulated persist — the image becomes the control) → remove (back to empty). This mirrors the real S3ImageUploader wrapper flow: presign → PUT → update currentSrc.
1600×643 recommended, max 5MB
Usage
The minimum is a label and onChange. For persisted images (already saved on the record), pass currentSrc — the image becomes the control (click to replace). For avatar/profile images, use variant="avatar" with initials for the empty slot.
import { ImageUploader } from '@epasslive/design-system'
// Minimum — empty dropzone
<ImageUploader label="Event banner" onChange={setFiles} />
// Persisted image — click to replace
<ImageUploader
label="Banner"
aspect="wide"
currentSrc={event.bannerUrl}
onRemove={() => setEvent({ ...event, bannerUrl: null })}
onChange={setFiles}
/>
// Avatar variant — 96px circle with initials fallback
<ImageUploader
label="Organizer photo"
variant="avatar"
initials="GB"
currentSrc={user.avatarUrl}
onChange={setFiles}
/>
// Multiple files — chip list with remove
<ImageUploader
label="Gallery"
multiple
hint="Pick up to 10 images"
onChange={setFiles}
/>Aspect guidance
square: 400×400 — profile images, card thumbnails.wide: 1600×643 — banners (the real product ratio, ~2.49:1).auto: h-24 w-full — full-width strip (default).
Previews are sized by height (h-44 = 176px), so a square and a wide on the same row line up exactly. A width-driven square at full form width would render as tall as the form is wide — height-driven sizing avoids that.
Accessibility
- Visible
<button type="button">trigger opens the native file dialog — Enter/Space work natively. Hidden input issr-only+tabIndex={-1}+aria-hidden(correct "redirect input" pattern, no double tab stop). - Accessible name via
aria-labelledbypointing at the visible label span. - Hint wired via
aria-describedbyto the Caption id. - Error rendered with
role="alert"— announced on insertion. - Remove controls:
removeLabelprop withRemove {label}fallback; chip removearia-label={Remove {file.name}}; tap targetmin-h/min-w-[var(--ep-tap-min)]. - Focus styling:
focus-visible:ring-2 ring-ep-tealon trigger,focus-within:ringon preview container.
Known gaps (queued for step 6):
- Error state not programmatically associated — no
aria-invalidoraria-describedby→error on the trigger.role="alert"announces once on appearance but a screen-reader user revisiting the control finds no linked error. - Clickable
<img>chip (onPreview) is keyboard-inaccessible — no role/tabIndex/key handler. WCAG 2.1.1 (A) failure whenonPreviewis provided. maxSizeoversize files silently filtered — zero feedback. The S3ImageUploader wrapper re-validates and sets error, but molecule-level users won't.
i18n contract
The molecule hardcodes English copy for trigger text ("Click to upload image", "Click to replace", "Click to upload photo", "Click to add images") and remove labels (Remove {label}, Remove {file.name}). The i18n catalog defines imageUploader.{addMultiple, addSingle, remove} keys but they are not wired. No override props exist for trigger copy — the S3ImageUploader wrapper cannot translate strings it doesn't control. Queued for step 6: either wire the keys or add string override props.
Drag-drop posture
ImageUploader is click-only — no drag-drop. Native HTML5 drag-drop lives in the FileUpload organism (and TicketDragDrop). The §122 barrel comment correctly scopes drag-drop coverage to FileUpload, so ImageUploader lacking it is consistent with the dedup. For desktop banner uploads that need drag-drop, use FileUpload.
Object-URL hygiene + duplicate-filename edge
Previews are created via URL.createObjectURL and revoked in the same effect — no leaks. The previews map is keyed by file.name, so two files named a.jpg overwrite each other. Chips are keyed {{file.name}-{i}} which partially masks it. Edge case; fix = key by {name}-{size}-{lastModified}.
Props
label*stringVisible label above the control. Also provides the accessible name via aria-labelledby on the trigger button.
onChange*(files: File[]) => voidFired when the user picks files. Receives the new file list (single file when multiple=false, appended when multiple=true).
valueFile[]Controlled file list. When provided, the component is fully controlled; otherwise it manages internal state. value only ever holds files picked in the current session — persisted images live in currentSrc.
acceptstringimage/*MIME accept pattern for the file input. Restrict to specific formats (e.g. "image/png, image/jpeg") when the backend enforces it.
multiplebooleanfalseAllow multiple file selection. When true, picked files append to the existing list; when false, the new pick replaces.
maxSizenumberPer-file size cap in bytes. Files exceeding this are silently filtered out (no error surfaced — the S3ImageUploader wrapper re-validates and sets error).
hintstringHelp text below the control (e.g. "400×400 recommended, max 2MB"). Rendered as a Caption wired via aria-describedby. Replaced by error when error is set.
errorstringError message (replaces hint). Rendered with role="alert" for screen-reader announcement. Known gap: no aria-invalid or aria-describedby→error wiring on the trigger — queued for step 6.
disabledbooleanfalseDisable the control (cursor-not-allowed, opacity-50).
currentSrcstring | nullURL of an image already persisted on the record. When set (and no new files picked), the image becomes the control: click to replace, or use the remove button. Without this, callers hand-rolled a preview row above the dropzone.
aspect'square' | 'wide' | 'auto''auto'Shape of the currentSrc preview. square: 400×400 (profile images). wide: 1600×643 (banners — the real product ratio). auto: h-24 w-full (full-width strip). Previews are sized by HEIGHT (h-44), so a square and a wide on the same row line up exactly.
variant'default' | 'avatar''default'avatar: fixed 96px circle with initials fallback (the surface the removed AvatarUploader molecule used to own — D1 dedup merge 4, §122). default: rectangular dropzone driven by aspect.
initialsstringInitials shown inside the empty avatar slot when variant="avatar" and no image is set (e.g. "GB"). Ignored in default mode.
onRemove() => voidShown as a remove control over the currentSrc preview when provided. The IconButton is positioned absolute top-right; the wrapper span (not the IconButton className) owns the positioning to avoid the Tailwind cascade trap (IconButton base starts with relative, which wins over absolute at equal specificity).
removeLabelstringAccessible name for the remove control. Falls back to `Remove ${label}` when not provided.
onPreview(url: string, file: File) => voidFired when the user clicks a picked-file chip. Known gap: the clickable <img> is keyboard-inaccessible (no role/tabIndex/key handler) — WCAG 2.1.1 failure when onPreview is provided. Queued for step 6.
classNamestringAdditional CSS classes merged onto the root container.
idstringRoot element id. Falls back to a generated useId; also derives the -label / -hint / -error aria ids.
Props
ImageUploader
valueFile[]uncontrolledControlled file list — only files picked in the current session (a persisted image lives in currentSrc).
onChange(files: File[]) => void—Emits the picked files (uncontrolled internal state when value is omitted).
acceptstring'image/*'File input accept attribute.
multiplebooleanfalseAllow several images; picked files render as preview chips.
maxSizenumber—Max size in bytes — oversize files are silently filtered (no message yet, tracked).
labelstring—Control label; feeds the trigger's accessible name and error/hint wiring.
hintstring—Helper text under the control (e.g. 'PNG, 400×400').
errorstring—Error text — renders a role='alert' message and styles the control invalid.
disabledbooleanfalseDisable the trigger and remove control.
onPreview(url: string, file: File) => void—Clicking a preview chip calls this with the object URL — render a lightbox from the consumer.
classNamestring—Additional CSS classes.
idstringauto (useId)Stable root id; keys the hint/error description ids.
currentSrcstring | null—URL of an image already persisted on the record — when set (and nothing new picked) the image becomes the control.
aspect'square' | 'wide' | 'auto''auto'Dropzone/preview shape (square 400×400, wide 1600/643); ignored in avatar mode.
variant'default' | 'avatar''default''avatar' renders a fixed circular 96px slot with initials fallback (the removed AvatarUploader surface).
initialsstring—Initials inside the empty avatar slot (e.g. 'GB'); ignored in default mode.
onRemove() => void—Renders the remove control over the currentSrc preview when provided.
removeLabelstring'Remove {label}'Accessible name for the remove control (EN template default — pass a localized string on non-EN pages).
<ImageUploader />Accessibility
ARIA
1 ruleType
1 ruleUsage
Import
import { ImageUploader } from "@epasslive/design-system"Example 1
<ImageUploader label="Event banner" aspect="wide" currentSrc={bannerUrl} onRemove={clearBanner} onChange={setFiles} />Example 2
<ImageUploader label="Avatar" variant="avatar" initials="GB" currentSrc={avatarUrl} onChange={setAvatar} />Anti-patterns
— Do not hand-roll a preview row above the picker — currentSrc IS the control
— Do not expect drag-and-drop — wire the FileUpload organism for that interaction