Image field
An image shown in the shape it's used in, with a place to drop, paste, or choose a file when there's none.
Image field picks an image and shows it as what it is, not as a link: the image in the shape it's used in, with its name, Replace, and remove. With no image it offers the ways to add one. A file can be dropped on it, pasted, or chosen, the recent images sit under that, and a link can be pasted last. Your app stores the files through an image library, and Block editor uses the same field for every image in a letter.
Drop a logo here, or paste one
A PNG with a see-through background, at least 64px tall.JPEG, PNG, GIF, or WebP up to 4 MB.
Recent
Usage
'use client';
import * as React from 'react';
import { ImageField, type ImageLibrary, ImageLibraryProvider } from '@/components/ui/image-field';
export function LogoField({ library }: { library: ImageLibrary }) {
const [logo, setLogo] = React.useState('');
return (
<ImageLibraryProvider library={library}>
<ImageField label="Logo" shape="logo" src={logo} onSrcChange={setLogo} />
</ImageLibraryProvider>
);
}Shapes
shape shows the image the way it's used, so the field tells you how it will look before you
save it. The recent images take the same shape, so a logo is never cropped to a square.
wideis the image at its own proportions, up to 192px tall. It's the default.logois 32px tall, the height of a letterhead, and never cropped. It sits on a checkerboard so a see-through background reads as one.cardis the 16:10 crop a card shows on a phone.roundis a person's photo, 64px across.
wide
logo

card
round
Each shape says what makes a good file for it where one is dropped, like "A PNG with a
see-through background, at least 64px tall" for a logo. Once an image is picked, the field
loads it and checks it against twice the size it's shown at, for sharp screens. A logo under
64px tall, a round photo under 80px across, or a card image under 600px wide gets a warning with
its size, like "This logo is 40px tall, so it'll look soft." A wide image can sit in a row at
any width, so it isn't checked, and an SVG or a field isn't either.
As a tile
variant="tile" shows the image as a square button, the way a profile shows its photo. With
no image it reads Add and the label, and either way it opens the same choices in a popover, with
Remove under them once there's an image. A file can still be dropped or pasted on it. The tile
fills the width of the element around it, so size it there, and shape still sets the tip and
the sharpness check for where the image is used.
<div className="w-40">
<ImageField label="Photo" shape="card" variant="tile" src={photo} onSrcChange={setPhoto} />
</div>Where images come from
ImageLibraryProvider gives every Image field inside it your app's library. upload saves a
file and resolves to it, and assets are the images you already have, newest first. A file just
uploaded joins the front of assets for the session, so another field on the page offers it
too. Reject upload with an Error and its message shows under the drop area.
const library = React.useMemo<ImageLibrary>(
() => ({
assets: files.map((file) => ({ url: file.publicUrl, name: file.fileName })),
upload: async (file) => {
const saved = await saveAsset(file);
return { url: saved.publicUrl, name: saved.fileName };
},
uploadHint: 'JPEG, PNG, GIF, or WebP up to 4 MB.',
}),
[files],
);accept sets the file types the upload takes, and JPEG, PNG, GIF, and WebP when you leave it
out. A dropped or pasted file of another type is turned away by name before it uploads.
Without upload there's nowhere to drop a file, and without a library at all the field takes a
link.
The field shows the nine newest images, each with its name under it and a check on the one in
use. When the library holds more, a search box finds any of them by name, matching every word
typed, and Show all lists every one in a scrolling grid. Both search the assets you pass,
so pass every image you want findable, not just the newest few. Replace opens the same
choices in a popover, and a file dropped or pasted on a picked image replaces it. The demo at the
top has sixteen logos to search.
Images that fill in when it sends
fields lets an image come from a field at send, like a unit's photo, instead of one fixed
image. Pass how to read a field from a source, how to show a picked one, and the link box that
can pick one. The link box then reads Use a link or field, and a picked field shows as what
it is with "Fills in when it sends". Block editor passes its variables this way, so in a letter
{{unit.imageUrl}} shows as a chip with the variable's name.
const fields: ImageFieldFields = {
read: (src) => /^\{\{\s*([\w.]+)\s*\}\}/.exec(src)?.[1],
Field: ({ fieldKey }) => <span>{labelFor(fieldKey)}</span>,
Input: FieldLinkInput,
};
<ImageField label="Photo" shape="round" src={src} onSrcChange={setSrc} fields={fields} />;Props
Image field takes these seven props. The library comes from ImageLibraryProvider.
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | What the image is, like Logo. Names the group, the upload, and the remove button, and words the drop area. | |
src | string | The image's URL, or a field when fields reads one. Empty for none. | |
onSrcChange | (src: string) => void | Called with the image picked, uploaded, or linked, and with "" when it's removed. | |
shape | "wide" | "logo" | "card" | "round" | "wide" | How the image shows, and the shape of the recent images. |
variant | "field" | "tile" | "field" | tile makes the image a square button that opens the choices in a popover. |
error | ReactNode | A problem with the image, shown under it. | |
fields | ImageFieldFields | Lets the image fill from a field when it sends. |
ImageLibraryProvider takes library, an ImageLibrary of assets, upload, accept, and
uploadHint, or undefined for links only. useImageLibrary reads it, for a control of your
own that picks from the same images.
Accessibility
- The field is a
groupnamed bylabel. - Dropping and pasting are shortcuts. Choose a file is the keyboard way to the same upload, and the file input behind it is named "Upload" and the label, and kept out of the tab order.
- A paste uploads while focus is anywhere in the field, including the Replace popover.
- A recent image is a toggle button named "Use" and its name, pressed when it's the one in use. Its name also shows under it, and the check is decorative.
- The search box is named "Search images", and
Enterin it doesn't submit a form. Show all says whether the full list is open witharia-expandedand points at it witharia-controls. - Replace opens a popover with focus inside, and
Esccloses it and returns focus to Replace. Picking an image closes it too. - Remove is an icon button named "Remove" and the label, with the same words as its tooltip.
- A tile is a button named by what it does, "Add photo" or "Change photo", and opens the popover
with focus inside.
Esccloses it and returns focus to the tile, and Remove is a button in the popover. The Change label that fades in over the image is decorative. - An upload that fails, or a file of the wrong type, shows as an alert under the drop area. A soft image's warning is a description under the field, in the warning tone.
- In the link box,
Enteruses the link and doesn't submit a form around the field. - The image previews have empty
alt, since the name sits beside them. - Under reduced motion the drop area changes color without a transition, and a tile's Change label shows without a fade.
When to use something else
- Input with
type="file"for a file that isn't an image, or one that isn't shown once it's picked, like a CSV to import. - Block editor already puts an Image field in
every image block's settings, with its variables as
fields.
Install
shadcn add @gesso/image-fieldIt brings Button, Icon button, Input, Field, and Popover.