---
title: "Image preview"
description: "Shows one picture of a set over the whole window when a person presses its thumbnail, to be looked at closely — enlarged and back, moved about while enlarged, and moved on to the next picture or back — until it is closed."
url: "https://tanrei.chnotchy.com/components/image-preview/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Image preview

> Shows one picture of a set over the whole window when a person presses its thumbnail, to be looked at closely — enlarged and back, moved about while enlarged, and moved on to the next picture or back — until it is closed.

## Overview

Use an image preview where a page shows a picture small because the page is about something else, and a person needs it large for a moment: a photograph in an article, the pictures of a product for sale, an attachment in a conversation, a screenshot in a guide. The person presses the thumbnail, looks, and is back where they were.

It shows still pictures and nothing else. It asks nothing of the person, so it is not a [dialog](https://tanrei.chnotchy.com/components/dialog/); its pictures are looked at and not pressed, so it is not a [carousel](https://tanrei.chnotchy.com/components/carousel/) — though a carousel’s item, or a picture in a [figure](https://tanrei.chnotchy.com/components/figure/), is a natural thing to open it from.

**Built with** [buttons](https://tanrei.chnotchy.com/components/button/) — zoom out, zoom in, close, previous and next — and a [skeleton](https://tanrei.chnotchy.com/components/skeleton/) that stands in for a picture on its way. They are the real components, reading their own tokens. The bar’s zoom out, zoom in and close are small buttons, tonal in the default system: the close button as every close button in Tanrei is, and the zoom controls beside it the same way. Previous and next are medium. The m3-like and cupertino presets keep bare glyphs on the bar. The stage that enlarges and moves a picture, and the turning from one picture to the next, are the image preview’s own and exist nowhere else.

On the web it is `<tanrei-image-preview>` with `<tanrei-image-preview-item>` children. The other platforms have not implemented it yet.

### Usage

The opener is yours. It is a button around your own picture — alone, in a figure, in a grid — that says it opens a dialog and is named for what it does and for which picture: “Enlarge: a bedroom with a window onto a garden”. The picture’s `alt` alone names the picture and not the action. Your handler for its press sets `index` to that picture and `open` to true. The thumbnails in the demo above are interactive [cards](https://tanrei.chnotchy.com/components/card/) named by `label`; a native button around the picture, as in the sample below, is the plain form and can say that it opens a dialog.

The preview never closes itself. It fires `close` with the reason — `button`, `escape`, `back` or `swipe` — and you set `open` to false. Focus then returns to the opener.

Each image has a `src`, the picture as the preview shows it, which is usually a larger file than the thumbnail, and an `alt`, which is required. Give `thumbnailSrc` — the small picture your page has already loaded — and the preview draws it enlarged until `src` arrives, so that opening is never blank. Give `width` and `height` where you know them, and the picture’s place is held before it loads. Only the picture shown and the one before and after it are asked for, however many there are.

**Web**

```html
<button type="button" class="thumbnail" aria-haspopup="dialog"
        aria-label="Enlarge: a bedroom with a window onto a garden" data-index="0">
  <img src="room-small.jpg" alt="" width="320" height="200" />
</button>
<button type="button" class="thumbnail" aria-haspopup="dialog"
        aria-label="Enlarge: the desk beside the window" data-index="1">
  <img src="desk-small.jpg" alt="" width="320" height="200" />
</button>

<tanrei-image-preview label="Photos of the room"
  close-label="Close" zoom-in-label="Zoom in" zoom-out-label="Zoom out"
  previous-label="Previous image" next-label="Next image"
  count-text="{position} of {count}"
  loading-label="Loading image" error-text="This image could not be loaded">
  <tanrei-image-preview-item src="room-large.jpg" thumbnail-src="room-small.jpg"
    width="1600" height="1000" alt="A bedroom with a window onto a garden"
    caption="The east bedroom in the morning."></tanrei-image-preview-item>
  <tanrei-image-preview-item src="desk-large.jpg" thumbnail-src="desk-small.jpg"
    width="1600" height="1000" alt="The desk beside the window"></tanrei-image-preview-item>
</tanrei-image-preview>

<script type="module">
  import '@tanrei/web/components/image-preview';

  const preview = document.querySelector('tanrei-image-preview');
  for (const thumbnail of document.querySelectorAll('.thumbnail')) {
    thumbnail.addEventListener('click', () => {
      preview.index = Number(thumbnail.dataset.index);
      preview.open = true;
    });
  }
  // The person asked to close it: the close control, Escape, back or a downward drag.
  preview.addEventListener('close', () => (preview.open = false));
  // The person moved to another picture. preview.index is already the new one.
  preview.addEventListener('indexchange', (e) => markAsSeen(e.detail.index));
</script>
```

**React**

```tsx
import { ImagePreview, ImagePreviewItem } from '@tanrei/react';

const [shown, setShown] = useState<number | null>(null);

{photos.map((photo, at) => (
  <button key={photo.id} type="button" aria-haspopup="dialog"
          aria-label={`Enlarge: ${photo.alt}`} onClick={() => setShown(at)}>
    <img src={photo.small} alt="" width={320} height={200} />
  </button>
))}

<ImagePreview label="Photos of the room" open={shown !== null} index={shown ?? 0}
  closeLabel="Close" zoomInLabel="Zoom in" zoomOutLabel="Zoom out"
  previousLabel="Previous image" nextLabel="Next image" countText="{position} of {count}"
  loadingLabel="Loading image" errorText="This image could not be loaded"
  onIndexChange={(event) => setShown(event.detail.index)}
  onClose={() => setShown(null)}>
  {photos.map((photo) => (
    <ImagePreviewItem key={photo.id} src={photo.large} thumbnailSrc={photo.small}
      width={photo.width} height={photo.height} alt={photo.alt} caption={photo.caption} />
  ))}
</ImagePreview>
```

The words are yours: the five controls’ names, the sentence of the count, what is said while a picture loads and when it fails. Give them on each element, or give them one time for all previews with the product’s words (`setWords()`). The count is a whole sentence in your language with two marked places, `{position}` and `{count}`, so that the order of the words is your language’s. In Japanese, for example: `{count}枚中{position}枚目`.

### Every operation, three ways

Nothing is done by a gesture alone. Each operation has a control a single press reaches, and a key.

| To | By a gesture | By a control | By a key |
| --- | --- | --- | --- |
| Enlarge, and return to fit | Two taps or two clicks on a point; a pinch; a trackpad pinch; Ctrl or Command with the wheel | Zoom in, zoom out | `+` or `=`, `−`, `0` |
| Move about an enlarged picture | A drag; the wheel | — | The arrow keys |
| Go to the next or the previous picture | A sideways drag | Next, previous | Left and Right Arrow at fit; Page Up and Page Down; Home and End for the first and the last picture |
| Close | A downward drag by touch, at fit | Close | Escape |
| Hide and show the controls | One tap or click on the picture | — | Any key shows them |

- **Zoom** is a number: 1 is the picture whole in the free area of the window, never above its own size. The steps are 1, 2 and 4 for an ordinary picture. A very wide or very tall picture, small at fit, fills the free area on its first step.
- **A drag moves between pictures only at fit**, or when it begins with an enlarged picture already at its edge on that side. A drag that reaches the edge under way stops there: a person moving about a picture never leaves it by accident. A finger or a pen turns to the next picture by a drag past half the stage or by a flick; a mouse turns to it only by a drag past half the stage, never by a flick, however fast it moves.
- **Changing the picture returns the zoom to 1.** The zoom belongs to a picture, not to the set.
- **The controls never hide by themselves.** A person hides them with one tap on the picture. Hidden, they stay in the focus order and are still told to assistive technology.
- `index`, `zoom` and `controlsHidden` are yours to set, and the preview writes them itself when the person changes them, before `indexchange`, `zoomchange` or `controlshiddenchange`.

### A larger source on demand

Change the `src` of the picture shown and the preview keeps drawing the earlier picture, at its zoom and its place, until the new one has loaded. So you can answer `zoomchange` with a file large enough for that zoom, and the person sees only more detail.

```js
preview.addEventListener('zoomchange', (e) => {
  if (e.detail.zoom > 2) preview.children[preview.index].src = largest(preview.index);
});
```

A picture that does not arrive is drawn as a mark with your `errorText`, and `loaderror` tells you which. Change its `src` and the preview asks again.

### One level down

The image preview is not built from other components, so you cannot write the same thing from its members. One level down is the platform’s own modal presentation, Tanrei buttons, a skeleton, and your own handling of the picture:

```html
<dialog class="viewer" aria-label="Photos of the room">
  <tanrei-button variant="tonal" size="small" label="Zoom out"><span slot="icon">zoom_out</span></tanrei-button>
  <tanrei-button variant="tonal" size="small" label="Zoom in"><span slot="icon">zoom_in</span></tanrei-button>
  <tanrei-button variant="tonal" size="small" label="Close"><span slot="icon">close</span></tanrei-button>
  <tanrei-button variant="tonal" label="Previous image"><span slot="icon">chevron_left</span></tanrei-button>
  <img alt="A bedroom with a window onto a garden" src="room-large.jpg" tabindex="0">
  <tanrei-button variant="tonal" label="Next image"><span slot="icon">chevron_right</span></tanrei-button>
  <p>The east bedroom in the morning.</p>
</dialog>
```

**This is not a near equivalent.** What you can take separately is the five buttons and the skeleton: the same elements, with the same tokens. What you cannot take is everything the image preview is: the zoom and its steps, the moving of an enlarged picture and its edges, the turning between pictures, the rule that decides whether a drag moves the enlarged picture or turns to another picture, the hiding of the controls, the loading of the neighbours only, and what assistive technology is told. A product that drops a level writes all of that, including the control and the key for every gesture.

| You need | Use |
| --- | --- |
| One picture of a set, large, to be examined and left | The image preview |
| A row of items on the page to browse and press | A [carousel](https://tanrei.chnotchy.com/components/carousel/), whose items may open an image preview |
| One picture on the page with a caption | A [figure](https://tanrei.chnotchy.com/components/figure/), whose picture may open an image preview |
| A question that must be answered | A [dialog](https://tanrei.chnotchy.com/components/dialog/) |
| Video, a document, a map, tools to rotate or crop | Your own viewer on the platform’s modal presentation |

## Specs

### Setting one preview apart

The surface is the theme’s own in light and in dark, and there is no scrim: the preview leaves nothing of the window uncovered. A product whose pictures want a dark stage in both themes re-points the fills and the content colors in its layer. One instance is set apart on the element:

```html
<tanrei-image-preview label="Plans"
  style="--tanrei-comp-image-preview-layout-page-gap: 32px"></tanrei-image-preview>
```

The two variants of its buttons are tokens too: `comp.image-preview.bar.button.variant` (text) and `comp.image-preview.navigation.button.variant` (tonal). The buttons and the skeleton read their own tokens, `comp.button.*` and `comp.skeleton.*`.

- Category: Containment
- Level: feature
- Listing: default
- Built from: Modal layer
- Page: https://tanrei.chnotchy.com/components/image-preview/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/image-preview.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-image-preview>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/image-preview`).
- React: `ImagePreview` from `@tanrei/react`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-image-preview` | lagging |
| SwiftUI | `tanreiImagePreview` | planned |
| Jetpack Compose | `TanreiImagePreview` | planned |
| Flutter | `TanreiImagePreview` | planned |
| React Native | `ImagePreview` | planned |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `open` (prop) | `open` | `isPresented` | `visible` | `open` | `open` |
| `label` (prop) | `label` | `label` | `label` | `semanticLabel` | `label` |
| `closeLabel` (prop) | `closeLabel` | `closeLabel` | `closeLabel` | `closeButtonSemanticLabel` | `closeLabel` |
| `previousLabel` (prop) | `previousLabel` | `previousLabel` | `previousLabel` | `previousButtonSemanticLabel` | `previousLabel` |
| `nextLabel` (prop) | `nextLabel` | `nextLabel` | `nextLabel` | `nextButtonSemanticLabel` | `nextLabel` |
| `zoomInLabel` (prop) | `zoomInLabel` | `zoomInLabel` | `zoomInLabel` | `zoomInButtonSemanticLabel` | `zoomInLabel` |
| `zoomOutLabel` (prop) | `zoomOutLabel` | `zoomOutLabel` | `zoomOutLabel` | `zoomOutButtonSemanticLabel` | `zoomOutLabel` |
| `loadingLabel` (prop) | `loadingLabel` | `loadingLabel` | `loadingLabel` | `loadingSemanticLabel` | `loadingLabel` |
| `close` (event) | `close` | `onClose` | `onCloseRequest` | `onClose` | `onClose` |
| `indexChange` (event) | `indexchange` | `index` | `onIndexChange` | `onIndexChanged` | `onIndexChange` |
| `zoomChange` (event) | `zoomchange` | `zoom` | `onZoomChange` | `onZoomChanged` | `onZoomChange` |
| `controlsHiddenChange` (event) | `controlshiddenchange` | `controlsHidden` | `onControlsHiddenChange` | `onControlsHiddenChanged` | `onControlsHiddenChange` |
| `loadError` (event) | `loaderror` | `onLoadError` | `onLoadError` | `onLoadError` | `onLoadError` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `container`: The surface that fills the window and holds everything else. It stands on the modal layer, which makes everything behind it inert, and the layer draws no scrim (its scrim is false), because the container leaves nothing uncovered.
- `stage`: The whole of the container as the place the image is shown, zoomed and moved. The preview's own part: it takes the gestures and the keys, it is the one focus stop that is not a Button, and it is exposed as the image shown.
- `image`: The picture shown, at fit or enlarged, and its neighbour beside it while one replaces the other. Never cropped, mirrored or recolored.
- `placeholder` (optional): What stands in the image's place while it is on its way: its thumbnail enlarged, or a Skeleton's block.
- `error` (optional): The mark and the product's sentence in place of an image that did not arrive.
- `bar`: The row across the top: the count at its start and the zoom-out, zoom-in and close controls at its end. A zoomed image passes under it.
- `count` (optional): The product's sentence that says which image of how many is shown. Drawn only while there are two or more.
- `zoom-out`: An icon-only Button that takes the zoom one step down; disabled at fit.
- `zoom-in`: An icon-only Button that takes the zoom one step up; disabled at the greatest zoom.
- `close`: An icon-only Button at the end of the bar that asks to close the preview.
- `previous` (optional): An icon-only Button at the inline start of the stage that shows the image before; disabled at the first. Drawn only while there are two or more images.
- `next` (optional): An icon-only Button at the inline end of the stage that shows the image after; disabled at the last. Drawn only while there are two or more images.
- `caption` (optional): The text the product gives about the image shown, across the bottom on its own fill; it scrolls inside itself where it is long.
- `focus-ring` (optional): The system's focus indicator, inside the edge of the fit area while the stage has keyboard focus and inside the caption's region while that scrolls and has it.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `open` | `boolean` | `false` | `open` | Whether the preview is presented. The product opens it — from its own thumbnail, with index set to that image — and closes it in answer to close; on SwiftUI, where it is a binding, the preview writes false itself. |
| `index` | `number` | `0` | `index` | Which image is shown, counted from 0 in the order the images are given. The person changes it by a drag, the previous and next controls and the keys, and the preview then emits indexChange; a product that sets it emits nothing. A number outside the images is taken as the nearest that exists (IMP-20). |
| `zoom` | `number` | `1` | `zoom` | How far the image shown is enlarged: 1 is its fit in the window, 2 twice that. The person changes it by a double tap, a pinch, the zoom controls and the keys, and the preview then emits zoomChange; it returns to 1 whenever the image shown changes. Kept between 1 and the greatest zoom (IMP-12). |
| `maxZoom` | `number` | — | `max-zoom` | The greatest zoom. null takes the greater of 4 and twice the zoom at which the image fills the window's free area, so that a very wide or very tall image can still be read (IMP-12). A product whose pictures hold fine detail — a map, a plan — gives more; one whose pictures are small gives less. |
| `controlsHidden` | `boolean` | `false` | `controls-hidden` | Whether the bar, the count, the previous and next controls and the caption are not drawn, so that nothing stands over the picture. The person changes it by one tap on the picture, and the preview then emits controlsHiddenChange; any key, keyboard or assistive-technology focus on a control, or a moving mouse shows them again (IMP-25). Hidden controls stay in the focus order and exposed to assistive technology. The preview never hides them by itself. |
| `label` | `string` | `""` | `label` | The accessible name of the preview, in the product's language ("Photos of the room", "Attachment"). Never drawn. Required. |
| `closeLabel` | `string` | `""` | `close-label` | The close control's accessible name ("Close"). Required. |
| `previousLabel` | `string` | `""` | `previous-label` | The previous control's accessible name ("Previous image"). Required while there are two or more images. |
| `nextLabel` | `string` | `""` | `next-label` | The next control's accessible name ("Next image"). Required while there are two or more images. |
| `zoomInLabel` | `string` | `""` | `zoom-in-label` | The zoom-in control's accessible name ("Zoom in"). Required. |
| `zoomOutLabel` | `string` | `""` | `zoom-out-label` | The zoom-out control's accessible name ("Zoom out"). Required. |
| `countText` | `string` | `""` | `count-text` | The sentence that says which image of how many is shown, drawn in the bar and told to assistive technology. The product writes the whole sentence in its own language and marks two places in it, which the preview fills: {position} with the place of the image shown, counted from 1, and {count} with the number of images ("{position} of {count}", "{count}枚中{position}枚目"). The product's: Tanrei has no sentence for it. Empty reads the product's words. Required while there are two or more images. |
| `loadingLabel` | `string` | `""` | `loading-label` | What assistive technology is told while the image shown is on its way ("Loading image"): the name of the Skeleton that stands in its place. Required where an image may be shown before it has loaded and has no thumbnailSrc. |
| `errorText` | `string` | `""` | `error-text` | The sentence drawn, and told to assistive technology, in place of an image that did not arrive ("This image could not be loaded"). The same for every image of the set; what failed and why is the product's to report elsewhere, from loadError. Required. |

### Events

| Event | Description |
| --- | --- |
| `close` | The person asked to close the preview: the close control, Escape, the platform's back, or a downward drag by touch. Carries which, named button, escape, back or swipe (in the platform's casing). The product closes it by setting open to false; on SwiftUI the binding is written first. |
| `indexChange` | The person moved to another image; carries the new index. Not emitted when the product sets index. |
| `zoomChange` | The zoom changed by the person's gesture, control or key, or returned to 1 because the image changed; carries the new zoom, once the change has ended. Not emitted when the product sets zoom. A product may answer it by giving the image a larger src (IMP-30). |
| `controlsHiddenChange` | The person hid or showed the controls; carries the new controlsHidden. Not emitted when the product sets it. |
| `loadError` | The source of an image the preview asked for did not load; carries that image's index. Emitted once for each source that fails. |

### Item: `image`

One picture of the set: where it is, what it shows in words, and optionally what the product says about it.

Names: Web `tanrei-image-preview-item`, SwiftUI `TanreiPreviewImage`, Jetpack Compose `TanreiPreviewImage`, Flutter `TanreiPreviewImage`, React Native `ImagePreviewImage`.

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `src` | `string` | `""` | `src` | The address of the picture as the preview shows it — usually a larger file than the thumbnail on the page. Required. Changed while the image is shown, the new picture replaces the old one in place once it has loaded (IMP-30). |
| `alt` | `string` | `""` | `alt` | What the picture shows, in the product's language: the name assistive technology reads for it. Required for every image; a preview holds no decoration (IMP-27). Never drawn. |
| `caption` | `string` | `""` | `caption` | What the product says about the picture, drawn below it and read as its description: a credit, a date, a sentence. Plain text of any length; a long one scrolls in its own region (IMP-26). Empty draws no caption. |
| `thumbnailSrc` | `string` | `""` | `thumbnail-src` | The address of a smaller picture of the same image that the page has usually loaded already — the thumbnail the person pressed. Drawn enlarged in the image's place until src has loaded, in place of the Skeleton (IMP-29). |
| `width` | `number` | — | `width` | The picture's own width in pixels, where the product knows it. With height it lets the preview hold the image's place, and size the thumbnail or the Skeleton, before the picture has loaded. null: learned when it loads. |
| `height` | `number` | — | `height` | The picture's own height in pixels; see width. |

### States

`enabled`, `focused`, `dragged`, `loading`, `error`

### Accessibility

- Role: `dialog`
- Labeling: The preview is a modal dialog named by label. Its stage is one image, named by the alt of the image shown and described by its caption; the count's sentence is its position. A change of image is told politely: the count, then the alt (IMP-28). The five controls are Buttons named by closeLabel, previousLabel, nextLabel, zoomInLabel and zoomOutLabel; a control at the end of its range is disabled, and focus on it moves to the stage. The zoom is told to no one: it changes what is seen, not what the picture is. While an image loads the Skeleton says it is busy under loadingLabel; when one fails, errorText is told. Everything outside the preview is hidden from assistive technology while it is open.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Move through zoom out, zoom in, close, previous, the stage, next and the caption where it scrolls, wrapping; focus never leaves the preview (IMP-3). Focus starts on the stage (IMP-2). A hidden control shows itself, with the others, when focus reaches it (IMP-25). |
| Left Arrow / Right Arrow | At fit: show the image on that side (the one before is on the left in a left-to-right layout, on the right in a right-to-left one). Zoomed: move the view over the image toward that side (IMP-23). |
| Up Arrow / Down Arrow | Zoomed: move the view over the image up or down. At fit: nothing. |
| Page Up / Page Down | Show the image before / after, at any zoom. |
| Home / End | Show the first / the last image. |
| + or = / − / 0 | Zoom in one step / zoom out one step / return to fit (IMP-13). |
| Escape | Asks to close (emits close with the reason escape); with other overlays above it, only the topmost closes. |
| Enter or Space | Activate the focused control. On the stage they do nothing. |

- Nothing is done by a gesture alone (WCAG 2.5.1, 2.5.7). A pinch and a double tap have the zoom controls and + / − / 0; a drag between images has the previous and next controls and the arrow, Page and Home / End keys; a drag over a zoomed image has the arrow keys; the downward drag has the close control and Escape. On a touch screen without a keyboard, moving about a zoomed image needs a one-finger drag, which is the single-pointer way, and zooming out brings every part back into view.
- The opener is the product's, not the preview's: a button around the product's own picture — standing alone, in a Figure's stage, in a grid — that says it opens a dialog and is named for what it does and which picture ("Enlarge: a bedroom with a window onto a garden"), in the product's words. It is a button, not a link: it has no destination. A picture's alt alone names the picture and not the action.
- Focus returns to the element that opened the preview (IMP-4). Where the person moved to another image and the product's page shows its thumbnail, the product may move focus to it after close; the preview does not know the page.
- Alt text is each image's own and is required: the preview shows the pictures a person chose to look at. The caption is what the product says about the picture and is not a substitute for the alt.
- The controls can be hidden by one tap, never by a timer (IMP-24), and never from the keyboard or from assistive technology: hidden, they are only not drawn. This holds WCAG 2.2.1 and 2.4.7 without a setting.
- The controls are Tanrei Buttons and meet the minimum target themselves (BTN-5); comp.image-preview.bar.gap keeps their targets apart in the default system. The stage is the whole window.
- Text size: at 200% in a window 320 wide the count wraps and the bar grows, the caption grows to a third of the window and then scrolls, and the image is fitted to what is left (IMP-39). Only a zoomed image is ever moved in two directions.
- Reduced motion removes the growth on opening, the slide between images and the eased zoom step; an image still follows the finger that moves it (IMP-34).
- Web: the browser's own page zoom still works on the preview; the stage takes pinch and scroll gestures for itself (`touch-action: none`), so the zoom controls and keys are the guaranteed way to enlarge.

### Behaviour

Requirements in EARS form, each with its stable id.

- **IMP-1** WHEN open becomes true and there is at least one image THE image preview SHALL present its container on its modal layer without the layer's scrim (MDL-1 WHILE scrim is false, MDL-2): above everything in its window and over the whole of it, filled with comp.image-preview.container.color, with everything outside it inert and the content behind it kept from scrolling, until it closes; it SHALL be presented by the platform's own modal presentation, and it draws no scrim, because nothing of the window is left uncovered.
- **IMP-2** WHEN the image preview opens THE image preview SHALL move focus to its stage, so that assistive technology reads the image shown (IMP-27) first and the keys of IMP-23 act at once; a preview mounted with open true SHALL move focus in as if it had just opened. Focus SHALL move when the preview opens, not when its motion ends.
- **IMP-3** WHILE the image preview is open THE image preview SHALL keep focus inside it, as its modal layer does (MDL-4): Tab SHALL move through the zoom-out control, the zoom-in control, the close control, the previous control, the stage, the next control and the caption where it scrolls (IMP-26), in that order in either layout direction, leaving out what is not drawn for want of a second image or of a caption; Tab from the last SHALL move to the first and Shift+Tab from the first to the last, and screen-reader navigation SHALL NOT leave it.
- **IMP-4** WHEN the image preview closes THE image preview SHALL return focus to the element that had it when the preview opened or, where that element no longer exists or cannot take focus, to the platform's default for a closed overlay, and SHALL restore the content behind it to what it was before, as its modal layer does (MDL-2, MDL-5); it SHALL NOT look for the product's thumbnail of the image last shown, which it does not know — a product that wants focus there moves it after the preview has closed. Focus SHALL move when the preview closes, not when its motion ends.
- **IMP-5** WHEN the user activates the close control, presses Escape, uses the platform's back (as its modal layer answers them, MDL-6) or completes the downward drag of IMP-22 THE image preview SHALL emit close exactly once with the reason button, escape, back or swipe, whatever the zoom is; it SHALL then be closed through its binding where open is one (SwiftUI), and by the product setting open to false where open is a plain value (web, Compose, Flutter, React Native). THE image preview SHALL have no setting that takes these ways out away from the person: it asks nothing of the person.
- **IMP-6** WHEN the image preview opens while a dialog or a modal sheet is open THE image preview SHALL stack above it, make it inert as well, and on closing return focus to the element inside it that had focus, as modal layers stack (MDL-8); WHEN another overlay opens from the image preview THE image preview SHALL stay below it and receive back the focus that overlay returns; Escape SHALL close only the topmost overlay.
- **IMP-7** THE image preview SHALL expose its container as its modal layer's modal dialog (MDL-10), named by label; WHILE label is empty it SHALL still open, and in debug builds SHALL report that it has no accessible name. THE image preview SHALL NOT add to, replace or read the window's navigation history or its address: where a product wants its own Back control to close the preview, the product writes that entry and sets open to false when it is left.
- **IMP-8** THE image preview SHALL draw a bar across the top of its container, inside the window's safe area, at least comp.image-preview.bar.height tall and filled with comp.image-preview.bar.container.color, holding at its start the count (IMP-9) and at its end, in this order toward the end edge, the zoom-out control, the zoom-in control and the close control, comp.image-preview.bar.gap apart, the first and the last control comp.image-preview.bar.padding-inline from the safe area's side edges, all centred on the bar's height. Each control SHALL be an icon-only Button of the small size in the variant comp.image-preview.bar.button.variant names — the three of them alike, the close control as every host's close button and the zoom controls as the close control beside them — given through the channel a Button reads from the component that places it and otherwise unrestyled, keeping its own minimum target (BTN-5), showing the icon set's zoom_out, zoom_in and close glyphs and named by zoomOutLabel, zoomInLabel and closeLabel; WHILE one of those names is empty it SHALL still draw the control, and in debug builds report that the control has no accessible name.
- **IMP-9** WHILE there are two or more images THE image preview SHALL draw the count at the start of the bar: countText with the place marked {position} filled with the place of the image shown, counted from 1, and the place marked {count} with the number of images, each written as the platform writes a whole number in the language of the image preview, in comp.image-preview.count.typescale and comp.image-preview.count.color, with comp.image-preview.count.padding-inline before and after it; the count SHALL wrap onto more lines, growing the bar, rather than be cut, and SHALL NOT take focus. WHILE there is one image THE image preview SHALL draw no count; WHILE there are two or more and countText is empty it SHALL draw none and in debug builds report that the count has no words. Any other text between braces in countText, and a brace that opens or closes nothing, SHALL be drawn as it is written and in debug builds reported; a brace written twice SHALL be drawn as one brace.
- **IMP-10** WHILE there are two or more images THE image preview SHALL draw a previous control at the inline start of its stage and a next control at its inline end, each comp.image-preview.navigation.margin-inline from the safe area's side edge and centred on the height of the fit area (IMP-11): icon-only Buttons of the medium size in the variant comp.image-preview.navigation.button.variant names, given through the same channel and otherwise unrestyled, showing the icon set's chevron_left and chevron_right glyphs and named by previousLabel and nextLabel, with the debug report of IMP-8 for an empty name. WHILE the first image is shown the previous control SHALL be disabled, and WHILE the last is shown the next control SHALL be: the set does not go round. WHILE there is one image THE image preview SHALL draw neither.
- **IMP-11** THE image preview SHALL make its stage the whole of its container, and SHALL call fit area the part of the stage inside the window's safe area, below the bar and above the caption where one is drawn (IMP-26). WHILE zoom is 1 it SHALL draw the image shown at the largest size at which the whole of it lies inside the fit area with its proportions kept, never above the image's own size, centred in the fit area; it SHALL NOT crop, stretch, mirror or recolor an image at any zoom, and where an image has clear parts comp.image-preview.container.color shows through them.
- **IMP-12** THE image preview SHALL hold a zoom for the image shown: a number, 1 being the size of IMP-11 and every other value that many times it. Its greatest value SHALL be maxZoom or, WHILE maxZoom is null, the greater of 4 and twice the fill zoom — the fill zoom being the least zoom at which the image covers the whole fit area. Its steps SHALL be 1, then the greater of 2 and the fill zoom, then each step twice the one before, none above the greatest value, which is itself the last step: so that an ordinary picture steps 1, 2, 4, and a very wide or very tall one, small at fit, fills the fit area across at its first step. WHEN the product sets zoom to a value outside 1 and the greatest value THE image preview SHALL use the nearer of the two, and WHEN maxZoom is less than 1 it SHALL treat it as null, and in debug builds report either.
- **IMP-13** WHEN the user activates the zoom-in control or presses + or = THE image preview SHALL move the zoom to the next step above it, and WHEN the user activates the zoom-out control or presses − to the next step below it, and WHEN the user presses 0 to 1, each about the centre of the fit area — the point of the image there stays there, as far as IMP-17 allows — over comp.image-preview.motion.zoom-duration with comp.image-preview.motion.zoom-easing. WHILE zoom is 1 the zoom-out control SHALL be disabled; WHILE zoom is the greatest value, or the image shown has not loaded or has failed (IMP-29, IMP-31), the zoom-in control SHALL be disabled.
- **IMP-14** WHEN the user taps the stage twice, or presses it twice with a mouse, within the platform's own interval for a double tap THE image preview SHALL, WHILE zoom is 1, move the zoom to its first step above 1 with the point of the image under the tap kept under it, as far as IMP-17 allows, and, WHILE zoom is above 1, move it to 1, over comp.image-preview.motion.zoom-duration with comp.image-preview.motion.zoom-easing; the two taps SHALL NOT also answer as single taps (IMP-24).
- **IMP-15** WHEN the user pinches the stage with two fingers, pinches on a trackpad, or turns a wheel while holding Ctrl (Command on Apple platforms) THE image preview SHALL change the zoom with the gesture, without a duration of its own, about the point midway between the fingers or under the pointer, never below 1 and never above the greatest value; two fingers that move together SHALL also move the image (IMP-16). WHEN the gesture ends the zoom SHALL stay where it is, not move to a step.
- **IMP-16** WHILE zoom is above 1, WHEN the user drags the stage with one finger, a pen or a mouse's main button past the platform's touch slop THE image preview SHALL move the image with the pointer, and continue and slow a fling as the platform's scrolling does; a wheel or a two-finger scroll on a trackpad without Ctrl or Command SHALL move it likewise, up and down and, with a sideways wheel or Shift, sideways. WHILE zoom is 1 a wheel SHALL do nothing.
- **IMP-17** THE image preview SHALL limit where a zoomed image can be moved so that each edge of the image can be brought to the matching edge of the fit area and no farther — so that no part of it stays under the bar or the caption — and SHALL keep the image centred in the fit area along an axis on which it is not larger than the fit area; WHEN a zoom would leave the image outside those limits THE image preview SHALL bring it inside them as part of the same motion.
- **IMP-18** WHILE there are two or more images and zoom is 1, WHEN the user drags the stage sideways with one finger, a pen or a mouse's main button, the drag having begun more sideways than up or down, THE image preview SHALL move the image with the pointer and draw its neighbour following it comp.image-preview.layout.page-gap away; WHEN the drag is released past half the stage's width it SHALL show that neighbour (IMP-20); WHEN a drag by a finger or a pen is released sooner but flung toward that side (the platform's own fling speed and touch slop, or 0.5 points per millisecond and 8 points where the platform names none) it SHALL show that neighbour likewise; a drag released after the pointer has not moved for longer than the time after which the platform's velocity tracker takes a pointer as stopped (40 milliseconds in Android's VelocityTracker, Jetpack Compose's VelocityTracker and Flutter's VelocityTracker) or, where the platform names none (the web, SwiftUI, React Native), 40 milliseconds has no velocity, and THE image preview SHALL take it as no fling — a page's here, a pan's (IMP-16) and a swipe's downward (IMP-22) alike; a drag by a mouse SHALL NOT show a neighbour for its speed, whatever that is — a mouse's drag shows one by its distance alone — because a mouse moves at a fling's speed in ordinary use and is dragged in repeated strokes; and a drag that shows no neighbour SHALL bring the image back, either over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing. Where there is no neighbour on that side the image SHALL follow the pointer by no more than the platform's own overscroll allows, and come back. A drag is a mouse's where the platform says that the pointer that makes it is a mouse — a mouse, or a trackpad that moves a cursor and presses; where the platform does not tell the kind of a pointer the drag SHALL be taken as a finger's.
- **IMP-19** WHILE zoom is above 1, WHEN a sideways drag begins while the image already stands at its limit (IMP-17) on the side the drag would uncover THE image preview SHALL treat that drag as the drag of IMP-18, with the release rule IMP-18 gives its kind of pointer — a finger's or a pen's past half the stage's width or flung, a mouse's past half the stage's width alone, the distance counted from where the drag began — and move to the neighbour without first returning to fit; a drag that reaches the limit while it is under way SHALL stop the image there and SHALL NOT move to another image, so that a person moving about a picture never leaves it by accident.
- **IMP-20** WHEN the user activates the previous or the next control, presses a key of IMP-23 that moves between images, or releases the drag of IMP-18, THE image preview SHALL show the image before or after the one shown — the one arriving sliding in from its side as the one leaving slides out, comp.image-preview.layout.page-gap apart, over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing — set index to its place and emit indexChange once with the new index. WHEN the image shown changes, by the person or by the product setting index, THE image preview SHALL return the zoom to 1 and the image that was left to its fit, emit zoomChange where the zoom was not 1, and say the new image (IMP-28). WHEN the product sets index it SHALL show that image at once, without the slide, and emit no indexChange; an index outside the images SHALL be taken as the nearest one that exists, with a debug report.
- **IMP-21** WHEN the person changes the zoom THE image preview SHALL set zoom and emit zoomChange with the new value once the gesture, the step or the return has ended, not for each frame of it; WHEN the person hides or shows the controls (IMP-24, IMP-25) it SHALL set controlsHidden and emit controlsHiddenChange with the new value. WHEN the product sets zoom or controlsHidden THE image preview SHALL take the value at once and emit nothing.
- **IMP-22** WHILE zoom is 1, WHEN the user drags the stage downward by touch, the drag having begun more downward than sideways, THE image preview SHALL move the image with the finger and make its container, bar, controls and caption clearer in proportion to the distance, so that the page behind shows; WHEN the drag is released past a quarter of the stage's height, or flung downward, it SHALL emit close with the reason swipe (IMP-5), hold the image where it was left for one frame, and then close from there IF open has become false, or bring everything back IF open is still true; a drag released sooner SHALL bring everything back over comp.image-preview.motion.page-duration with comp.image-preview.motion.page-easing. An upward drag, and a downward drag by a mouse, SHALL NOT close it.
- **IMP-23** WHILE focus is anywhere in the image preview, except in a part that uses the key itself (the scrolling caption of IMP-26), THE image preview SHALL answer these keys: WHILE zoom is 1, Left Arrow and Right Arrow SHALL show the image that lies on that side — the one before on the left and the one after on the right in a left-to-right layout, and the reverse in a right-to-left one — and Up Arrow and Down Arrow SHALL do nothing; WHILE zoom is above 1 the four arrow keys SHALL move the view over the image toward that side by a quarter of the fit area's width or height, within the limits of IMP-17; at any zoom Page Up SHALL show the image before and Page Down the image after, Home the first and End the last; + or = , − and 0 SHALL change the zoom (IMP-13); and Escape SHALL ask to close (IMP-5). A key that has nothing to do — an arrow at the first or the last image — SHALL do nothing and SHALL NOT leave the preview.
- **IMP-24** WHEN the user taps the stage once, or presses it once with a mouse, without dragging and without a second tap within the platform's interval for a double tap, THE image preview SHALL hide its controls where they are shown and show them where they are hidden (IMP-25). THE image preview SHALL NOT hide its controls by itself: not after a time without input, and not when the zoom changes.
- **IMP-25** WHILE controlsHidden is true THE image preview SHALL draw neither the bar, the count, the previous and next controls nor the caption, fading them over comp.image-preview.motion.controls-duration with comp.image-preview.motion.controls-easing, and SHALL NOT move or resize the image for it: the fit area stays as IMP-11 measures it with the controls shown. The controls SHALL stay in the focus order of IMP-3 and exposed to assistive technology with their names and states, and the keys of IMP-23 SHALL act as before. WHEN a key is pressed, focus moves to any of the controls by the keyboard or by assistive technology, a pointer that can hover moves over the preview, or the stage is tapped (IMP-24), THE image preview SHALL show them again, fading in over the same duration, before the control answers.
- **IMP-26** WHILE the image shown has a caption THE image preview SHALL draw it at the bottom of its container, across its width and inside the window's safe area, on a fill of comp.image-preview.caption.container.color: the text in comp.image-preview.caption.typescale and comp.image-preview.caption.color, wrapping and never cut, its lines beginning at the start of a column at most comp.image-preview.caption.max-width wide that is centred in the container, with comp.image-preview.caption.padding-block above and below and at least comp.image-preview.caption.padding-inline before and after. The caption's region SHALL be no taller than a third of the container; WHILE the text needs more it SHALL scroll inside the region, which is then one stop in the focus order, exposed as a group, scrolled by the arrow keys, Page Up, Page Down, Home and End as the platform scrolls a region, and drawn WHILE it has keyboard focus with the system's focus indicator inside its edge — sys.focus.outer-width wide in the color role focus-ring, set in by sys.focus.offset, never animated. WHILE the image shown has no caption THE image preview SHALL draw no region, and the fit area reaches the bottom of the safe area.
- **IMP-27** THE image preview SHALL expose its stage as one image that takes focus, named by the alt of the image shown and described by its caption, with the count's sentence (IMP-9) as the stage's position where the platform has a place for one and at the beginning of its description where it has none; it SHALL expose no other image of the set, SHALL expose the zoom and the place within the image to no one, and WHILE the stage has keyboard focus SHALL draw the system's focus indicator inside the edge of the fit area as IMP-26 draws the caption's; focus gained by pointer or touch SHALL NOT show it. WHILE an image's alt is empty THE image preview SHALL still show it, and in debug builds report that the image has no accessible name: a picture a person chose to look at closely is never decoration.
- **IMP-28** WHEN the image shown changes while the image preview is open THE image preview SHALL have assistive technology told, politely and once, the count's sentence and then the new image's alt, whether or not focus is on the stage, and SHALL keep focus where it is — but WHEN the control that has focus becomes disabled by the change (the previous control at the first image, the next at the last, a zoom control at the end of its range) THE image preview SHALL move focus to the stage, so that focus is never on a control that cannot be reached again.
- **IMP-29** WHILE the source of the image shown has not loaded THE image preview SHALL draw in its place, WHERE the image has a thumbnailSrc, that picture at the size the image will have at fit — from the image's width and height where they are given, and from the thumbnail's own proportions where they are not — and, WHERE it has none, a Skeleton holding one block placeholder of that size, or of the whole fit area where neither width nor height is given, named by loadingLabel and otherwise unrestyled: it reads the Skeleton's own tokens and waits, moves and says that it is busy as a Skeleton does (SKL-2, SKL-4). The zoom SHALL stay 1 while it waits; moving to another image and closing SHALL work as ever.
- **IMP-30** WHEN the source of the image shown has loaded THE image preview SHALL draw it in place of the thumbnail or the Skeleton at once, without moving what the person sees where the proportions are the same. WHEN the src of the image shown changes after it has loaded THE image preview SHALL keep drawing the earlier picture, at its zoom and its place, until the new one has loaded, and then draw the new one at the same zoom and place — so that a product may answer zoomChange with a larger source and the person sees only more detail.
- **IMP-31** IF the source of the image shown fails to load THE image preview SHALL draw in the middle of the fit area the icon set's broken_image glyph at comp.image-preview.error.icon.size in comp.image-preview.error.icon.color — with the family and fill of an unselected icon, sys.icon.weight and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max — and, comp.image-preview.error.gap below it, errorText in comp.image-preview.error.text.typescale and comp.image-preview.error.text.color, centred and wrapping; it SHALL keep the stage named by the image's alt, add errorText to its description, have assistive technology told errorText politely, and emit loadError once for that source with the image's index. The zoom SHALL stay 1; moving to another image and closing SHALL work as ever; WHEN that image's src changes THE image preview SHALL ask for it again. WHILE errorText is empty it SHALL draw the glyph alone and in debug builds report that the failure has no words.
- **IMP-32** THE image preview SHALL ask for the source of the image shown and of the one before and the one after it, and of no other image of the set, however many there are, so that a neighbour is ready when the person moves to it; it SHALL be free to let go of a source that is no longer one of those three, and SHALL ask again when it becomes one.
- **IMP-33** WHEN the image preview opens THE image preview SHALL fade its container, bar, controls and caption in, over comp.image-preview.motion.enter-duration with comp.image-preview.motion.enter-easing (its modal layer's tempo, MDL-7), while the image appears as every modal surface appears: from sys.motion.appear.opacity, sys.motion.appear.scale, sys.motion.appear.distance and sys.motion.appear.blur to whole, grown about the centre of its size at fit and travelling up into place, on both axes WHERE sys.behavior.appearance is unfold; WHEN it closes it SHALL fade all of it out over comp.image-preview.motion.exit-duration with comp.image-preview.motion.exit-easing while the image goes back the same way to where it started. A motion turned round SHALL go on from the opacity and the size it has reached. WHEN it is mounted with open true THE image preview SHALL appear without its motion while the document has not yet been first painted, and with it when it is mounted after that. It SHALL NOT fly the image from or to the place of the product's thumbnail.
- **IMP-34** WHERE motion is reduced THE image preview SHALL open and close by fading, the image over sys.motion.role.reduced with no scale, distance or blur — or at once on a platform whose reduced-motion setting removes every animation (Android's) — SHALL change the zoom of IMP-13 and IMP-14 and the image of IMP-20 at once, without a slide, SHALL show and hide its controls at once, and SHALL still let the image follow a finger or a pointer in a pinch or a drag, which is the person's own motion; the settle of a released drag SHALL then be at once.
- **IMP-35** WHEN the layout direction is right-to-left THE image preview SHALL mirror the order of its parts: the count at the right of the bar and its controls at the left, the close control leftmost; the previous control at the right of the stage and the next at the left; the image after the one shown lying to the left, so that a drag toward the right brings it and Left Arrow shows it (IMP-23); and the caption's lines beginning at the right. It SHALL NOT mirror an image, the direction a zoomed image moves under a drag or an arrow key, or the point a double tap or a pinch keeps in place.
- **IMP-36** WHERE the layout direction is right-to-left THE image preview SHALL have the chevron_left and chevron_right glyphs of its previous and next controls drawn mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, changing neither its place, size, color nor the order IMP-35 gives; THE image preview SHALL NOT mirror a glyph itself, and SHALL NOT mirror the zoom_in, zoom_out, close and broken_image glyphs, which point nowhere.
- **IMP-37** THE image preview SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the image preview is shown THE image preview SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state, its image, its zoom and its focus.
- **IMP-38** THE image preview SHALL lay out its count, its caption and its error text as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE image preview SHALL still place each and align its lines by the layout direction, not by the text's own.
- **IMP-39** WHILE the text size is enlarged THE image preview SHALL grow its count, its caption and its error text with it, grow the bar where the count wraps and the caption's region up to its limit (IMP-26), measure the fit area anew and draw the image to fit it, and SHALL NOT scale its controls beyond what a Button does itself; in a window 320 wide with text at twice its size nothing SHALL be cut or need scrolling sideways, a zoomed image apart, which is moved by IMP-16 and IMP-23.
- **IMP-40** WHEN the size of its container or of the safe area changes — a window resized, a device turned, an on-screen bar shown — THE image preview SHALL measure the fit area anew and, WHILE zoom is 1, draw the image to fit it, and, WHILE zoom is above 1, keep the zoom and the point of the image at the middle of the fit area there, within the limits of IMP-17, without a motion.
- **IMP-41** WHERE the platform draws with a person's own forced colors (the web's forced-colors mode, and a platform's equivalent where it has one) THE image preview SHALL fill its container, bar and caption with the platform's background color, draw its count, caption and error text and the broken_image glyph in the platform's text color and the focus indicators of IMP-26 and IMP-27 in the platform's own color for focus, and SHALL leave every image exactly as it is; its controls are Buttons and adapt as Buttons do.
- **IMP-42** THE image preview SHALL let each of its controls answer hover, keyboard focus and a press as any Button does — its states, its minimum target, its pressed feedback and its focus indicator are the Button's (BTN-5, BTN-8, BTN-9, BTN-10) — and SHALL draw on its stage no state layer, no ripple and no pressed or hovered look of its own: a pointer over an image, or a press on it, changes nothing that is drawn but what the gesture itself moves.
- **IMP-43** IF open becomes true while there is no image THE image preview SHALL present nothing, leave the window as it is, and in debug builds report as an error that a preview was opened with nothing to show. WHEN the images change while the image preview is open THE image preview SHALL keep index within them (IMP-20), return the zoom to 1 where the image shown is no longer the same one, and draw or drop the count and the previous and next controls as IMP-9 and IMP-10 say for the new number.
- **IMP-44** WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE image preview SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its dialog (IMP-7) — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the dialog's name, over a name given so; WHILE label is empty, a name given so is the dialog's name and SHALL NOT be reported as having none in debug builds. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE image preview SHALL take it off that element.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.image-preview.…` → `--tanrei-comp-image-preview-…`).

#### `tanrei.comp.image-preview`

| Token | Type | Value |
| --- | --- | --- |
| `comp.image-preview.container.color` | `color` | `{tanrei.sys.color.surface}` |
| `comp.image-preview.bar.height` | `dimension` | `56px` |
| `comp.image-preview.bar.container.color` | `color` | `{tanrei.sys.color.surface}` |
| `comp.image-preview.bar.padding-inline` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.image-preview.bar.gap` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.image-preview.bar.button.variant` | `number` | `1` |
| `comp.image-preview.count.typescale` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.image-preview.count.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.image-preview.count.padding-inline` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.image-preview.navigation.button.variant` | `number` | `1` |
| `comp.image-preview.navigation.margin-inline` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.image-preview.caption.container.color` | `color` | `{tanrei.sys.color.surface}` |
| `comp.image-preview.caption.typescale` | `typography` | `{tanrei.sys.typescale.body}` |
| `comp.image-preview.caption.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.image-preview.caption.padding-block` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.image-preview.caption.padding-inline` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.image-preview.caption.max-width` | `dimension` | `640px` |
| `comp.image-preview.layout.page-gap` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.image-preview.error.icon.size` | `dimension` | `{tanrei.sys.icon.large}` |
| `comp.image-preview.error.icon.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.image-preview.error.text.typescale` | `typography` | `{tanrei.sys.typescale.body}` |
| `comp.image-preview.error.text.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.image-preview.error.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.image-preview.motion.enter-duration` | `duration` | `{tanrei.comp.modal-layer.motion.enter-duration}` |
| `comp.image-preview.motion.enter-easing` | `cubicBezier` | `{tanrei.comp.modal-layer.motion.enter-easing}` |
| `comp.image-preview.motion.exit-duration` | `duration` | `{tanrei.comp.modal-layer.motion.exit-duration}` |
| `comp.image-preview.motion.exit-easing` | `cubicBezier` | `{tanrei.comp.modal-layer.motion.exit-easing}` |
| `comp.image-preview.motion.zoom-duration` | `duration` | `{tanrei.sys.motion.duration.medium1}` |
| `comp.image-preview.motion.zoom-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.standard}` |
| `comp.image-preview.motion.page-duration` | `duration` | `{tanrei.sys.motion.duration.medium2}` |
| `comp.image-preview.motion.page-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.emphasized-decelerate}` |
| `comp.image-preview.motion.controls-duration` | `duration` | `{tanrei.sys.motion.role.state.large}` |
| `comp.image-preview.motion.controls-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.state}` |

### What the web draws differently

- The element is a native `<dialog>` in a shadow root, opened modally: the page behind is inert and does not scroll. It writes no history entry; a product that wants the browser’s Back to close it pushes its own entry on open and sets `open` to false when that entry is left.
- `<tanrei-image-preview-item>` is data, not an `<img>`: the browser asks for no picture the preview has not asked for. An item without a `src` is asked for nothing and waits.
- Its events are CustomEvents that bubble: `close` (`detail.reason`), `indexchange` (`detail.index`), `zoomchange` (`detail.zoom`), `controlshiddenchange` (`detail.hidden`), `loaderror` (`detail.index`). The buttons’ own `click`s do not leave the element.
- Parts: `container`, `bar`, `count`, `stage`, `image`, `placeholder`, `error`, `caption`; each button under its name — `zoom-out`, `zoom-in`, `close`, `previous`, `next` — and each button’s own parts with that name before theirs: `close-container`, `next-icon`.
- The attributes are the kebab-case of the properties: `max-zoom`, `controls-hidden`, `close-label`, `previous-label`, `next-label`, `zoom-in-label`, `zoom-out-label`, `count-text`, `loading-label`, `error-text`; on an item, `thumbnail-src`.
- The stage takes pinch and scroll gestures for itself, so the browser’s own pinch does not zoom the page while the preview is open. The browser’s page zoom by the keyboard still works. A closed preview takes no wheel event and no touch from the page.
- A trackpad pinch arrives as the wheel with Ctrl, or as the browser’s own gesture event where it has one. Going to another picture by a sideways trackpad scroll is not offered.
- The picture fades in and grows a little as the preview opens. Where a browser cannot hold a closed dialog while it fades, the preview is gone at once when it closes; focus and the page are given back at the same moment either way.
- Android’s back closes it with the reason `back`. A browser that does not tell back from Escape reports `escape`.

## Guidelines

### Do

- Make the opener a button around the picture, say that it opens a dialog, and name it for the action and the picture.
- Set `index` to the picture that was pressed before you set `open`.
- Close it in answer to every `close`. To keep it open after a downward drag, leave `open` as it is: the picture comes back.
- Give every image an `alt` that says what it shows. Use `caption` for what you say about it: a credit, a date, a sentence.
- Give `thumbnailSrc`, `width` and `height` where you have them.
- Report a failure in your own terms from `loaderror`; `errorText` is one sentence for every picture.

### Don’t

- Don’t use it for a question, a form or anything a person must answer. It has no actions, and no setting can remove the ways to close it.
- Don’t hide the controls on a timer, and don’t open it with them hidden unless a single tap is plainly how your product shows them.
- Don’t put the only way to do something in a gesture of your own on top of it. Every operation here has a control and a key.
- Don’t write the address or a history entry while it is open unless you also close it when that entry is left.
- Don’t use an `<img>` as its child. The browser would load every picture of the set at once.

## Accessibility

### What the component handles

- It is a modal dialog named by `label`. Everything outside it is hidden from assistive technology and cannot be focused or pressed while it is open.
- Focus goes to the stage when it opens, stays inside — Tab goes through zoom out, zoom in, close, previous, the stage, next and a caption that scrolls, and after the last of them returns to the first — and returns to the element that had it when it closes.
- The stage is one image: its name is the `alt` of the picture shown, and its description is the count’s sentence and then the caption. No other picture of the set is exposed, and the zoom is told to no one.
- A change of picture is told politely, once: the count’s sentence, then the new picture’s `alt`.
- The five controls are buttons with your names. A control that has reached the end of its range is disabled, and focus that was on it moves to the stage.
- Every gesture has a control and a key. The controls can be hidden by one tap and never by a timer; hidden, they stay focusable and exposed, and any key, focus on one of them or a moving mouse shows them.
- While a picture loads, the skeleton says it is busy under `loadingLabel`. When one fails, `errorText` is drawn, told, and added to the stage’s description.
- A long caption takes at most a third of the window and scrolls in a region of its own, which is then a focus stop the keys scroll.
- In a right-to-left layout the bar, the sides of previous and next, their glyphs, the side the next picture lies on and the Left and Right Arrow keys are mirrored. The picture, and the way an enlarged picture moves, are not.
- With text at 200% in a window 320 wide, the count takes a line of its own and the bar grows, the caption grows to its third and scrolls, and the picture is fitted to what is left. In forced colors the fills and the text take the system’s colors and the pictures are left as they are.
- Where motion is reduced the preview opens and closes by fading, without growing, zoom steps and changes of picture happen at once, and a picture still follows the finger that moves it.

Known gaps on the web: what screen readers say has been read from the browsers’ accessibility trees and not heard with a screen reader, and the gestures have been driven by tests and not yet by hand on a phone and a trackpad.

### What you need to do

- Give `label`, and the names of the five controls, the count’s sentence, `loadingLabel` and `errorText`, in your language — on the element or once in the product’s words.
- Give every image an `alt`. A picture a person chose to look at closely is never decoration.
- Name the opener for what it does and for which picture, and mark it `aria-haspopup="dialog"`.
- Where the person moved to another picture and your page shows its thumbnail, you may move focus to that thumbnail after `close`. The preview does not know your page.
- On a touch screen without a keyboard, moving about an enlarged picture needs a one-finger drag. Keep the zoom-out control reachable: do not open the preview with the controls hidden for people who cannot drag.
