Skip to main content

ImageUploader

Implemented

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

Categorymolecules
Sourcepatterns/molecules/imageuploader.html
AI HintSingle-image record fields (logos, posters) and avatar slots (variant='avatar'). Drag-drop is the FileUpload organism's contract — this control is click-only. EN hardcoded trigger copy ('Click to upload…') has no override prop yet (tracked); the S3 wrapper is the canonical consumer.

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

Event banner
Profile image

400×400 recommended, max 2MB

Banner
Banner

1600×643 recommended

Organizer photo
Cover image
Gallery

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.

Event banner

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 is sr-only + tabIndex={-1} + aria-hidden (correct "redirect input" pattern, no double tab stop).
  • Accessible name via aria-labelledby pointing at the visible label span.
  • Hint wired via aria-describedby to the Caption id.
  • Error rendered with role="alert" — announced on insertion.
  • Remove controls: removeLabel prop with Remove {label} fallback; chip remove aria-label={Remove {file.name}}; tap target min-h/min-w-[var(--ep-tap-min)].
  • Focus styling: focus-visible:ring-2 ring-ep-teal on trigger, focus-within:ring on preview container.

Known gaps (queued for step 6):

  • Error state not programmatically associated — no aria-invalid or aria-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 when onPreview is provided.
  • maxSize oversize 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

Prop
Type
Default
Description
label*
string
—

Visible label above the control. Also provides the accessible name via aria-labelledby on the trigger button.

onChange*
(files: File[]) => void
—

Fired when the user picks files. Receives the new file list (single file when multiple=false, appended when multiple=true).

value
File[]
—

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.

accept
string
image/*

MIME accept pattern for the file input. Restrict to specific formats (e.g. "image/png, image/jpeg") when the backend enforces it.

multiple
boolean
false

Allow multiple file selection. When true, picked files append to the existing list; when false, the new pick replaces.

maxSize
number
—

Per-file size cap in bytes. Files exceeding this are silently filtered out (no error surfaced — the S3ImageUploader wrapper re-validates and sets error).

hint
string
—

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

error
string
—

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

disabled
boolean
false

Disable the control (cursor-not-allowed, opacity-50).

currentSrc
string | null
—

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

initials
string
—

Initials shown inside the empty avatar slot when variant="avatar" and no image is set (e.g. "GB"). Ignored in default mode.

onRemove
() => void
—

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

removeLabel
string
—

Accessible name for the remove control. Falls back to `Remove ${label}` when not provided.

onPreview
(url: string, file: File) => void
—

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

className
string
—

Additional CSS classes merged onto the root container.

id
string
—

Root element id. Falls back to a generated useId; also derives the -label / -hint / -error aria ids.

Props

Preview

ImageUploader

Prop
Type
Required
Default
Description
value
File[]
Optional
uncontrolled

Controlled file list — only files picked in the current session (a persisted image lives in currentSrc).

onChange
(files: File[]) => void
Required
—

Emits the picked files (uncontrolled internal state when value is omitted).

accept
string
Optional
'image/*'

File input accept attribute.

multiple
boolean
Optional
false

Allow several images; picked files render as preview chips.

maxSize
number
Optional
—

Max size in bytes — oversize files are silently filtered (no message yet, tracked).

label
string
Required
—

Control label; feeds the trigger's accessible name and error/hint wiring.

hint
string
Optional
—

Helper text under the control (e.g. 'PNG, 400×400').

error
string
Optional
—

Error text — renders a role='alert' message and styles the control invalid.

disabled
boolean
Optional
false

Disable the trigger and remove control.

onPreview
(url: string, file: File) => void
Optional
—

Clicking a preview chip calls this with the object URL — render a lightbox from the consumer.

className
string
Optional
—

Additional CSS classes.

id
string
Optional
auto (useId)

Stable root id; keys the hint/error description ids.

currentSrc
string | null
Optional
—

URL of an image already persisted on the record — when set (and nothing new picked) the image becomes the control.

aspect
'square' | 'wide' | 'auto'
Optional
'auto'

Dropzone/preview shape (square 400×400, wide 1600/643); ignored in avatar mode.

variant
'default' | 'avatar'
Optional
'default'

'avatar' renders a fixed circular 96px slot with initials fallback (the removed AvatarUploader surface).

initials
string
Optional
—

Initials inside the empty avatar slot (e.g. 'GB'); ignored in default mode.

onRemove
() => void
Optional
—

Renders the remove control over the currentSrc preview when provided.

removeLabel
string
Optional
'Remove {label}'

Accessible name for the remove control (EN template default — pass a localized string on non-EN pages).

Quick use:<ImageUploader />

Accessibility

ARIA rolebutton

ARIA

1 rule

WCAG 4.1.2

trigger is a button-composite with an accessible name from label; hidden file input redirects clicks; error renders role='alert'; hint is aria-describedby (error association is a tracked gap — aria-invalid not yet set)

Type

1 rule

NASA Rule 5

object URLs are revoked on unmount/replacement — do not cache the preview URLs outside the component

Usage

Import

tsxSnippet
import { ImageUploader } from "@epasslive/design-system"

Example 1

tsxSnippet
<ImageUploader label="Event banner" aspect="wide" currentSrc={bannerUrl} onRemove={clearBanner} onChange={setFiles} />

Example 2

tsxSnippet
<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

Design System v4.1.0 | ePassLive