Skip to content
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
Getting startedWebReactSwiftUIJetpack ComposeFlutterReact NativePresetsYour own design systemUsing Tanrei with AI agentsBrandDesign tokensDesign systems and customizationTheme StudioColorTypographyIconsShapeElevationMotionInteraction statesBehaviourLayoutAccessibilityWordsColor tokensTypescale tokensShape tokensElevation tokensMotion tokensState tokensSpace and measure tokensComponentsButtonMain action buttonSegmented buttonButton groupButton with menuToolbarCopy buttonCheckboxRadio buttonSwitchChipMenuSliderSlider fieldCalendarDateTimePickerField groupRadio groupCheckbox groupSelectSelect fieldMenubarFile uploadFile picker AdvancedDrop zoneText input AdvancedText fieldChip fieldFieldCombobox AdvancedCombobox fieldNumber input AdvancedNumber fieldColor input AdvancedColor fieldOne-time-code input AdvancedOne-time-code fieldEmbedded text input AdvancedChat composerCardDividerModal layer AdvancedDialogListList rowBottom sheetSide sheetCarouselCode blockTablePopoverStepsAccordionDescription listFigureApp shellPage headerTree viewTimelineImage previewHandleResizable frameScroll areaBadgeProgress indicatorMeterNumber tickerLoading indicatorSnackbarTooltipKeyboard shortcutTagInline codeCalloutAvatarEmpty stateTag groupSkeletonFile itemSwatchMarquee textTabsTab viewApp barNavigation barNavigation drawerSearchLinkPaginationPrevious and next linksStepperBreadcrumbsTable of contentsLink cardFAB menuStatusHow Tanrei is builtContributingLicense and attribution
HomeComponentsImage preview

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.

content_copyView as Markdown
Category
Containment
Tokens
comp.image-preview
Level
Feature
Built from
Modal layer

Overview

Web
tanrei-image-preview
schedule
SwiftUI
tanreiImagePreview
radio_button_unchecked
Jetpack Compose
TanreiImagePreview
radio_button_unchecked
Flutter
TanreiImagePreview
radio_button_unchecked
React Native
ImagePreview
radio_button_unchecked

Generated from docs/status/status.json, specs/image-preview.spec.json

Three pictures on a page: press one to look at it closely

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; its pictures are looked at and not pressed, so it is not a carousel — though a carousel’s item, or a picture in a figure, is a natural thing to open it from.

Built with buttons — zoom out, zoom in, close, previous and next — and a 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 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.

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

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:

<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, whose items may open an image preview
One picture on the page with a caption A figure, whose picture may open an image preview
A question that must be answered A 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:

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

Anatomy

Part Description
1 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.
2 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.
3 image The picture shown, at fit or enlarged, and its neighbour beside it while one replaces the other. Never cropped, mirrored or recolored.
4 placeholder · optional What stands in the image's place while it is on its way: its thumbnail enlarged, or a Skeleton's block.
5 error · optional The mark and the product's sentence in place of an image that did not arrive.
6 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.
7 count · optional The product's sentence that says which image of how many is shown. Drawn only while there are two or more.
8 zoom-out An icon-only Button that takes the zoom one step down; disabled at fit.
9 zoom-in An icon-only Button that takes the zoom one step up; disabled at the greatest zoom.
10 close An icon-only Button at the end of the bar that asks to close the preview.
11 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.
12 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.
13 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.
14 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.

Properties

Property Type Default Description
open boolean false 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 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 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 null 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 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 "" The accessible name of the preview, in the product's language ("Photos of the room", "Attachment"). Never drawn. Required.
closeLabel string "" The close control's accessible name ("Close"). Required.
previousLabel string "" The previous control's accessible name ("Previous image"). Required while there are two or more images.
nextLabel string "" The next control's accessible name ("Next image"). Required while there are two or more images.
zoomInLabel string "" The zoom-in control's accessible name ("Zoom in"). Required.
zoomOutLabel string "" The zoom-out control's accessible name ("Zoom out"). Required.
countText string "" 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 "" 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 "" 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.

Items

The kinds of item the component repeats. A fixture lists its items in order; each platform names each kind in its own idiom.

image

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

Property Type Default Description
src string "" 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 "" 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 "" 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 "" 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 null 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 null The picture's own height in pixels; see width.
Spec Web SwiftUI Jetpack Compose Flutter React Native
Item tanrei-image-preview-item TanreiPreviewImage TanreiPreviewImage TanreiPreviewImage ImagePreviewImage
src · Property src url model image source
alt · Property alt alt alt semanticLabel alt
caption · Property caption caption caption caption caption
thumbnailSrc · Property thumbnailSrc thumbnailURL thumbnailModel thumbnail thumbnailSource
width · Property width width width width width
height · Property height height height height height
Notes A data element, a child of the preview: it draws nothing and holds one image's props as attributes (thumbnail-src). The preview reads its children in order and watches them for change. It is not an <img>, so that the browser asks for no picture the preview has not asked for (IMP-32). A value (struct, Identifiable) in the images array. A data class in the images list. model and thumbnailModel are what the product's image loader takes (a URL string by default). A value in the images list. image and thumbnail are ImageProviders, Flutter's idiom for an address (a NetworkImage); the preview resolves only the shown image's and its neighbours' (IMP-32). An object in the images array. source and thumbnailSource are React Native's image sources ({ uri } or a require).

States

Behavior

Requirements in EARS form. Every platform implementation is tested against these ids.

ID Requirement
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.

Platform name mapping

Parity does not mean identical APIs. Each platform uses its own idiom for the same contract.

Spec Web SwiftUI Jetpack Compose Flutter React Native
Component tanrei-image-preview tanreiImagePreview TanreiImagePreview TanreiImagePreview ImagePreview
open · Property open isPresented visible open open
index · Property index index index index index
zoom · Property zoom zoom zoom zoom zoom
maxZoom · Property maxZoom maxZoom maxZoom maxZoom maxZoom
controlsHidden · Property controlsHidden controlsHidden controlsHidden controlsHidden controlsHidden
label · Property label label label semanticLabel label
closeLabel · Property closeLabel closeLabel closeLabel closeButtonSemanticLabel closeLabel
previousLabel · Property previousLabel previousLabel previousLabel previousButtonSemanticLabel previousLabel
nextLabel · Property nextLabel nextLabel nextLabel nextButtonSemanticLabel nextLabel
zoomInLabel · Property zoomInLabel zoomInLabel zoomInLabel zoomInButtonSemanticLabel zoomInLabel
zoomOutLabel · Property zoomOutLabel zoomOutLabel zoomOutLabel zoomOutButtonSemanticLabel zoomOutLabel
countText · Property countText countText countText countText countText
loadingLabel · Property loadingLabel loadingLabel loadingLabel loadingSemanticLabel loadingLabel
errorText · Property errorText errorText errorText errorText errorText
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
Notes A custom element with a shadow root: class TanreiImagePreview, React ImagePreview. Attributes are the kebab-case of the props (max-zoom, controls-hidden, close-label, previous-label, next-label, zoom-in-label, zoom-out-label, count-text, loading-label, error-text). Inside the shadow root: a native <dialog> opened with showModal() — the same modal layer the Dialog and the modal sheets stand on (primitives/modal.ts, primitives/overlay-focus.ts), not a <tanrei-dialog>, whose surface, headline and actions it has no use for — holding five real <tanrei-button> elements (the channel of BTN-10 gives each its variant), a <tanrei-skeleton> while an image without a thumbnail loads, and the preview's own stage: <img> elements for the image shown and its two neighbours and no other (IMP-32), moved and scaled with a transform. The dialog has max-width and max-height none and fills the viewport (100dvh); ::backdrop is clear. Parts: container, bar, count, stage, image, caption, error; the Buttons' parts are exported as close, zoom-in, zoom-out, previous, next followed by the Button's own part names (exportparts). Events are CustomEvents that bubble and leave the shadow root: close carries detail.reason and is not the native dialog's close; indexchange, zoomchange, controlshiddenchange and loaderror carry detail.index, detail.zoom, detail.hidden and detail.index. open, index, zoom and controlsHidden are plain values the element writes itself on a person's change, before the event, except open, which the product sets to false in answer to close. Gestures: pointer events with touch-action: none on the stage, so that the browser's own pinch zoom and scroll do not take them — which is why the zoom buttons and keys must always work (WCAG 2.5.1, 2.5.7); wheel with ctrlKey is both Ctrl+wheel and a trackpad pinch; a double tap is two pointerups within 300 ms and the touch slop, since dblclick does not come on touch. The kind of a pointer is the pointerType of its pointerdown: mouse is the mouse of IMP-18 and IMP-19 (a trackpad's press and drag arrives as one), touch and pen are the finger and the pen, and an empty or unknown one is taken as touch. The web has no overscroll of its own for a drag past the first or the last image (IMP-18), so the image follows three tenths of the drag beyond that edge, and comes back on release: near what the web's usual image viewers do (PhotoSwipe takes 0.35) and what iOS's edge resistance feels like, enough that the hand feels the end. Android's back closes it through the dialog's own close request (cancel), reported as back where the platform tells it from Escape and as escape where it does not. No history entry is written (IMP-7). The stage is role="img" with tabindex="0", aria-label the alt and aria-describedby the count and the caption; the announcement of IMP-28 is a polite live region. Hidden controls are opacity: 0, never display: none, visibility: hidden or inert. Forced colors: forced-color-adjust: none on the images only. The opener is the product's: a native <button type="button" aria-haspopup="dialog"> around its own <img> — in a <tanrei-figure> or not — named for what it does ("Enlarge: " and the alt, in the product's words), whose click sets index and open; the element ships no opener helper. Snapshot harness: a 640 × 640 viewport, and 360 × 640 for a compact- run of several and caption; the screenshot is the viewport, since the dialog is in the top layer; The fixtures' pictures are the package's own copies under packages/web/test/fixtures/, which the harness gives a page as data: addresses (test/pictures.ts, fixturePicture), so that no fixture asks the network for anything; fixtures/missing.png is the picture that fails at once, and fixtures/pending.png is the picture that never arrives — the harness gives that one name an address that is asked for and never answered, where every other name without a file fails at once. Planned. A view modifier, as .tanreiDialog is: .tanreiImagePreview(_ label:, isPresented: Binding<Bool>, images: [TanreiPreviewImage], index: Binding<Int>, zoom: Binding<Double>?, maxZoom:, controlsHidden: Binding<Bool>?, onClose:, onLoadError:); the word props default to the product's words in the environment. indexChange, zoomChange and controlsHiddenChange are writes to the bindings, SwiftUI's idiom; closing writes false to isPresented and then calls onClose with the reason. Presented with .fullScreenCover with the system transition off (Tanrei draws the fade), which makes the rest of the app unreachable to VoiceOver. The stage is the preview's own view: MagnifyGesture, DragGesture and a double TapGesture composed so that the single tap waits for the double one; a pointer's scroll and pinch on iPadOS and macOS through the platform's own events. The kind of the pointer that drags (IMP-18, IMP-19): DragGesture does not tell it, so the pager's drag reads it from the platform beneath — on iPadOS and visionOS a touch of the type indirectPointer is the mouse, direct the finger and pencil the pen; on macOS every drag is a mouse's; on a phone every drag is a finger's. The stage carries .accessibilityAddTraits(.isImage), the alt as its label, the caption as its hint and the count as its value, with .accessibilityAdjustableAction moving between images and .accessibilityAction(.escape) closing. Images load through AsyncImage's machinery or the product's loader given in the environment. Planned. TanreiImagePreview(visible, onCloseRequest: (TanreiPreviewCloseReason) -> Unit, images: List<TanreiPreviewImage>, index, onIndexChange, zoom, onZoomChange, maxZoom, controlsHidden, onControlsHiddenChange, onLoadError, label, …): state is hoisted, Compose's idiom, so the preview calls the callback and the caller writes the value. visible rather than calling it only while open, so that the exit motion can run. Presented in a Dialog window with usePlatformDefaultWidth = false, decorFitsSystemWindows = false and the window's dim off; dismissOnBackPress reports back. The stage is the preview's own composable: detectTransformGestures, detectTapGestures(onDoubleTap, onTap) and a horizontal drag that it hands over by the rule of IMP-19; it does not use a pager whose scroll would take a zoomed image's drag. The kind of the pointer that drags (IMP-18, IMP-19) is the type of its PointerInputChange: PointerType.Mouse is the mouse, Touch the finger, Stylus and Eraser the pen, and Unknown is taken as the finger. Semantics: Role.Image, contentDescription the alt, stateDescription the count, the caption as its text; paneTitle the label. Tanrei loads no picture itself on Android: the product gives a painter loader (a TanreiImageLoader composition local), since the platform has none; model is what that loader takes. Planned. TanreiImagePreview(open:, onClose: ValueChanged<TanreiPreviewCloseReason>, images: [...], index:, onIndexChanged:, zoom:, onZoomChanged:, maxZoom:, controlsHidden:, onControlsHiddenChanged:, onLoadError:, semanticLabel:, …), controlled: the caller sets each value from its callback. A controlled overlay in a Stack over the page, as the modal side sheet is, with a FocusScope that keeps focus in, the page's semantics blocked, back through PopScope and the keys through Shortcuts/Actions. The stage is the preview's own render object with a ScaleGestureRecognizer, a DoubleTapGestureRecognizer and a horizontal drag recognizer in one arena, resolved by the rule of IMP-19; it is not an InteractiveViewer inside a PageView, which cannot hand a drag at the edge to the page. The kind of the pointer that drags (IMP-18, IMP-19) is the kind of its pointer events: PointerDeviceKind.mouse is the mouse, touch the finger, stylus and invertedStylus the pen, and unknown is taken as the finger; a trackpad pointer drags nothing — its two-finger movement is the scroll of IMP-16. Semantics(image: true, label:, value:, hint:); the announcement is SemanticsService.announce. Planned. Controlled: open, onClose(reason), images, index, onIndexChange, zoom, onZoomChange, controlsHidden, onControlsHiddenChange, onLoadError; the caller sets each value from its callback. Presented in a transparent Modal with animationType="none", statusBarTranslucent and navigationBarTranslucent; onRequestClose is close with the reason back (or escape from a hardware keyboard). The stage is the preview's own view on PanResponder with the two-touch distance for a pinch, on the core library alone, as the package's other gestures are; whether a pinch that follows the fingers at the display's rate needs a gesture library the package does not have today is for the platform's agent to measure before it builds (the device check). The kind of the pointer that drags (IMP-18, IMP-19): PanResponder does not tell it, so on the core library every drag is taken as a finger's, which is what a phone has; where the build runs with a mouse (a tablet or a desktop target) and the platform's pointer events are on, nativeEvent.pointerType of onPointerDown tells it, and mouse is the mouse — which of the two the package can rely on is part of the same device check. accessibilityRole="image", accessibilityLabel the alt, accessibilityValue.text the count, accessibilityHint the caption; AccessibilityInfo.announceForAccessibility for IMP-28.

Tokens

Token Aliases Resolved (light)
--tanrei-comp-image-preview-bar-button-variant — 1
--tanrei-comp-image-preview-bar-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-bar-gap sys.space.space150 12px
--tanrei-comp-image-preview-bar-height — 56px
--tanrei-comp-image-preview-bar-padding-inline sys.space.space100 8px
--tanrei-comp-image-preview-caption-color sys.color.on-surface #1b1b1b
--tanrei-comp-image-preview-caption-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-caption-max-width — 640px
--tanrei-comp-image-preview-caption-padding-block sys.space.space150 12px
--tanrei-comp-image-preview-caption-padding-inline sys.space.space200 16px
--tanrei-comp-image-preview-container-color sys.color.surface #ffffff
--tanrei-comp-image-preview-count-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-count-padding-inline sys.space.space100 8px
--tanrei-comp-image-preview-error-gap sys.space.space100 8px
--tanrei-comp-image-preview-error-icon-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-error-icon-size sys.icon.large 32px
--tanrei-comp-image-preview-error-text-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-image-preview-layout-page-gap sys.space.space200 16px
--tanrei-comp-image-preview-motion-controls-duration sys.motion.role.state.large 250ms
--tanrei-comp-image-preview-motion-controls-easing sys.motion.easing.state cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-motion-enter-duration comp.modal-layer.motion.enter-duration 350ms
--tanrei-comp-image-preview-motion-enter-easing comp.modal-layer.motion.enter-easing cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-motion-exit-duration comp.modal-layer.motion.exit-duration 300ms
--tanrei-comp-image-preview-motion-exit-easing comp.modal-layer.motion.exit-easing cubic-bezier(0.3, 0, 1, 1)
--tanrei-comp-image-preview-motion-page-duration sys.motion.duration.medium2 300ms
--tanrei-comp-image-preview-motion-page-easing sys.motion.easing.emphasized-decelerate cubic-bezier(0.05, 0.7, 0.1, 1)
--tanrei-comp-image-preview-motion-zoom-duration sys.motion.duration.medium1 250ms
--tanrei-comp-image-preview-motion-zoom-easing sys.motion.easing.standard cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-image-preview-navigation-button-variant — 1
--tanrei-comp-image-preview-navigation-margin-inline sys.space.space100 8px
--tanrei-comp-image-preview-caption-typescale sys.typescale.body 400 14px/1.5714
--tanrei-comp-image-preview-count-typescale sys.typescale.label 500 14px/1.4286
--tanrei-comp-image-preview-error-text-typescale sys.typescale.body 400 14px/1.5714

Fixtures

Canonical renderings. Every platform snapshots each fixture under the same id so the results can be compared.

Generated from specs/image-preview.spec.json, tokens/comp/image-preview.tokens.json, design/tokens/

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

  • 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

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
The hit area meets the minimum target size even when the visual size is smaller. minimum 32px · pointer 44px · spacing 8px · touch 48px

Keyboard

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.
+ / = / − / 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 / Space Activate the focused control. On the stage they do nothing.

Notes

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

Generated from specs/image-preview.spec.json, tokens/sys/measure.tokens.json

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.