Image upload

● Stable · v0.1

Add photos as a grid of thumbnails — a receipt, a product, a shopfront — each showing its upload state right on the tile. The same controlled model as File upload, but you see the picture, not a filename.

In context

9:41

Try it

On the web this opens your picker (images only) and previews them instantly. On a phone it asks the app to open the camera/library picker.

Every state

Purpose

Image upload is for pictures, where the thumbnail itself is the label. A grid of tiles with an "add" tile at the end makes it obvious how many photos there are and how to add another, with each tile carrying its own progress or error.

Anatomy

  • Thumbnail tile — the image, cropped to fill a square (or a circle for a single avatar). Carries a remove (×) badge.
  • Add tile — a dashed tile with a plus that opens the picker. It hides once max is reached.
  • Per-tile overlay — a dimmed tile with a progress bar while uploading, or a red overlay with Retry if it fails.
  • Counter / helper — quiet text under the grid, showing n / max when a limit is set.

Behaviour

Controlled like File upload: you own the images array ({ id, uri, status?, progress?, error? }). On web the add tile calls onSelect with object-URL previews; on native it calls onRequestPick (wire expo-image-picker). Use shape="circle" with max={1} for a single avatar/logo.

Code

import { ImageUpload } from '@daytwo/components'
const [images, setImages] = useState([])
<ImageUpload
label="Add photos"
helperText="Up to 4 photos of your products"
max={4}
images={images}
onSelect={(picked) => addAndUpload(picked)} // web
onRequestPick={pickWithExpo} // native
onRemove={(id) => setImages((m) => m.filter((x) => x.id !== id))}
onRetry={(id) => retry(id)}
/>
// Single avatar:
<ImageUpload label="Shop logo" shape="circle" max={1} images={logo} />

When to use

  • For photos — products, a shopfront, a receipt the owner snaps with the camera.
  • For an avatar or logo (shape="circle", max={1}).
  • When seeing the picture matters more than seeing a filename.

When NOT to use

  • For documents (PDFs, statements), use File upload.
  • For a single profile photo with cropping UI beyond this — compose a dedicated flow.
  • To display a gallery that can't be edited — that's just images, not an upload.

Anti-patterns

Do
A thumbnail grid with a clear add tile and a counter, so the owner sees their photos and how many more they can add.
Don't
A bare 'Upload' button that hides the chosen photos — the owner can't tell what's attached or remove a wrong one.
Show the photo back

The whole point of an image upload is the preview. Always show the thumbnail so the owner can confirm they picked the right one — and remove it if not.

Edge cases

Uploading
Dim the tile with a progress bar over the image
WhyProgress stays tied to the specific photo
Failed
Red overlay on that tile with Retry
WhyOnly the failed photo retries, not all of them
Max reached
The add tile disappears; show n / max
WhyNo dead "add" that can't add
Large photos
Tiles are fixed-size and crop to fill
WhyThe grid stays even regardless of aspect ratio
Single avatar
shape="circle", max={1} — replaces in place
WhyReads as a profile photo, not a gallery
Disabled
Tiles greyed, remove + add hidden
WhyCommunicates "not now" cleanly
Slow connection
Keep the preview while it uploads in the background
WhyThe owner moves on; the photo isn't lost

Tokens used

color.surface.primarycolor.background.secondarycolor.brand.primarycolor.text.primarycolor.text.secondarycolor.text.tertiarycolor.border.defaultcolor.border.subtlecolor.feedback.errorBorderradius.mdradius.pilltypography.subhead