Gesso
Forms

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.

  • wide is the image at its own proportions, up to 192px tall. It's the default.
  • logo is 32px tall, the height of a letterhead, and never cropped. It sits on a checkerboard so a see-through background reads as one.
  • card is the 16:10 crop a card shows on a phone.
  • round is a person's photo, 64px across.

wide

card.svg

logo

ramson-light.png

card

card.svg

round

rep.svg

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.

PropTypeDefaultWhat it does
labelstringWhat the image is, like Logo. Names the group, the upload, and the remove button, and words the drop area.
srcstringThe image's URL, or a field when fields reads one. Empty for none.
onSrcChange(src: string) => voidCalled 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.
errorReactNodeA problem with the image, shown under it.
fieldsImageFieldFieldsLets 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 group named by label.
  • 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 Enter in it doesn't submit a form. Show all says whether the full list is open with aria-expanded and points at it with aria-controls.
  • Replace opens a popover with focus inside, and Esc closes 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. Esc closes 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, Enter uses 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-field

It brings Button, Icon button, Input, Field, and Popover.

On this page