GrImageViewer
Opens an image in a convenient viewing and zooming mode.
Overview
When to take it
- the picture has to be examined — a scan of a document, a drawing, a photograph of a product: zoom, rotation and panning are already there;
- there are several images —
urlListis paged with the arrows and by swiping, and the index is controlled throughv-model; - the original can be downloaded —
showDownloadgives a button next to the zoom; - the work is done from the keyboard — the whole set of actions has keys and labels.
When to take something else
| Need | Take |
|---|---|
| Show an image in the flow of the page | an ordinary <img> or GrCard |
| An avatar or a thumbnail | GrAvatar |
| A layout of your own on top of the page | GrModal |
| Choose and upload a file | GrFileUpload |
| A carousel gallery in the flow of the page | GrCarousel |
Playground controls come from the API, not from hand
The window opens above the page with the props set on the right.
0 changed· state is in the URL
<GrImageViewer
:urlList="urlList"
showProgress
v-model="value"
/>Examples 3 each with a live demo and its code
Photos from a service visit
initial-indexopens the viewer at the photo that was clicked,show-progresssays how many there are, andshow-downloadhands over the original. Every frame carries itsalt— «loose wiring across the breakers» — because the viewer cannot invent a description, and a file name is not one.Visit #5523 · Harbour Road office, cabinet B2Technician Mina Ortiz · Tue 6 Oct, 14:20–15:05 A floor plan read at eight times
Desk numbers on a level plan are small, and the viewer is how they get read:
max-scaleraises the limit to eight,zoom-ratemakes each step bigger, anddraggablelets even the fitted plan be moved. The wheel zooms into the point under the cursor, and «1:1» goes to real pixels rather than to «fitted».Your desk tomorrow: 4.17Level 4, east wing · two rows from the lifts
Reviewing identity scans
The second page came in sideways: rotation is built in, and the reviewer’s own button sits beside it through
toolbar-actionsinstead of replacing the toolbar. The slot hands over the frame — index,alt, rotation — so «Request a new scan» names the page it is about.Identity check · Jonas Weber2 pages uploaded today
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
ariaLabel | string | undefined | undefined | The accessible name of the layer. A modal dialog without a name is a violation of `aria-dialog-name`: a screen reader will announce "dialog" and fall silent. |
prevLabel | string | undefined | undefined | i18n: the aria-label of the "previous image" button. |
nextLabel | string | undefined | undefined | i18n: the aria-label of the "next image" button. |
emptyText | string | undefined | undefined | i18n: the text of the empty state (there are no images). |
closeLabel | string | undefined | undefined | i18n: the aria-label of the close button. |
showProgress | boolean | undefined | false | The "3 / 12" counter in the header. `false` by default. |
initialIndex | number | undefined | 0 | The frame the viewer opens with; changing the prop while it is open switches the frame. `0` by default. |
zoomRate | number | undefined | 1.2 | The multiplier of one zoom step — with the buttons and from the keyboard. `1.2` by default. |
minScale | number | undefined | 0.5 | The lower limit of the scale; `1` means the frame is fitted into the window. `0.5` by default. |
maxScale | number | undefined | 5 | The upper limit of the scale relative to the fitted frame. `5` by default. |
hideOnClickModal | boolean | undefined | false | Close on a click on the backdrop around the frame. `false` by default. |
closeOnPressEscape | boolean | undefined | true | Close on `Esc`; opened on top of a modal, it closes itself rather than the modal. `true` by default. |
showZoomValue | boolean | undefined | true | The scale in percent in the header; 100% means the frame is fitted into the window. `true` by default. |
wheelZoom | boolean | undefined | true | Switches on zooming with the mouse wheel or a trackpad gesture. On by default. |
draggable | boolean | undefined | false | Switches on dragging (panning) of the picture with the mouse. On hover the cursor becomes a hand. Off by default. |
zIndexVar | string | undefined | undefined | The name of the CSS variable of the layer — an escape hatch past `--gr-z-modal`. The component does not accept a raw number: the layer is set by the scale, see `docs/z-index.md`. |
zoomInLabel | string | undefined | undefined | i18n: the aria-label of the "zoom in" button. |
zoomOutLabel | string | undefined | undefined | i18n: the aria-label of the "zoom out" button. |
resetZoomLabel | string | undefined | undefined | i18n: the aria-label of the "reset the zoom" button. |
zoomToNaturalLabel | string | undefined | undefined | i18n: the aria-label of the "to the natural size" button. |
rotateLeftLabel | string | undefined | undefined | i18n: the aria-label of the "rotate left" button. |
rotateRightLabel | string | undefined | undefined | i18n: the aria-label of the "rotate right" button. |
showDownload | boolean | undefined | false | Show the button that downloads the current frame. |
downloadLabel | string | undefined | undefined | i18n: the aria-label of the "download" button. |
modelValuerequiredv-model | boolean | — | Whether the viewer is open (`v-model`). It closes itself — with the cross, `Esc` or the backdrop. |
urlListrequired | GrImageViewerSource[] | — | The frames. A string is an address only; an object `{ src, alt }` gives the image an alternative text: without it the viewer is empty for a blind user, and the component cannot invent the text on the consumer’s behalf. |
Slots
| Slot | Type | Description |
|---|---|---|
toolbar | GrImageViewerSlotProps | The toolbar as a whole instead of the built-in one. |
toolbar-actions | GrImageViewerSlotProps | Buttons of your own beside the built-in ones — rotation, downloading, printing. |
Events
| Event | Signature | Description |
|---|---|---|
close | (event: "close"): void | — |
update:modelValue | (event: "update:modelValue", value: boolean): void | — |
change | (event: "change", newIndex: number): void | — |
rotate | (event: "rotate", deg: number): void | — |
download | (event: "download", payload: { src: string; alt: string; index: number; }): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
close | () => void | Close the viewer as the cross does: emits `update:modelValue` and `close`. |
prev | () => void | The previous frame; from the first one it goes to the last. |
next | () => void | The next frame; from the last one it goes to the first. |
zoomIn | () => void | Zoom in by `zoomRate` times, no higher than `maxScale`. |
zoomOut | () => void | Zoom out by `zoomRate` times, no lower than `minScale`. |
zoomToNatural | () => void | The "one to one" scale: a real 100% rather than a nominal one. |
reset | () => void | Return the frame to its original view: the scale, the rotation and the offset. |
rotateLeft | () => void | Rotate 90° anticlockwise. |
rotateRight | () => void | Rotate 90° clockwise. |
download | () => void | Download the current frame — the same as the toolbar button does. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrImageViewerItem, GrImageViewerSource, GrImageViewerProps } from '@feugene/granularity/components/GrImageViewer'Filedist/types/components/GrImageViewer/GrImageViewer.vue.d.ts
type GrImageViewerItem = {
src: string;
alt?: string;
};type GrImageViewerSource = string | GrImageViewerItem;interface GrImageViewerProps {
modelValue: boolean;
urlList: GrImageViewerSource[];
initialIndex?: number;
zoomRate?: number;
minScale?: number;
maxScale?: number;
hideOnClickModal?: boolean;
closeOnPressEscape?: boolean;
showProgress?: boolean;
showZoomValue?: boolean;
wheelZoom?: boolean;
draggable?: boolean;
zIndexVar?: string;
closeLabel?: string;
prevLabel?: string;
nextLabel?: string;
zoomInLabel?: string;
zoomOutLabel?: string;
resetZoomLabel?: string;
zoomToNaturalLabel?: string;
rotateLeftLabel?: string;
rotateRightLabel?: string;
showDownload?: boolean;
downloadLabel?: string;
emptyText?: string;
ariaLabel?: string;
}interface GrImageViewerEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'close'): void;
(e: 'change', newIndex: number): void;
(e: 'rotate', deg: number): void;
(e: 'download', payload: {
src: string;
alt: string;
index: number;
}): void;
}Filedist/types/components/GrImageViewer/config.d.ts
const grImageViewerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageViewer">;Filedist/types/components/GrImageViewer/safelist.d.ts
const grImageViewerSafelist: string[];Filedist/types/components/GrImageViewer/index.d.ts
type GrImageViewerInstance = ComponentExposed<typeof GrImageViewerComponent>;Accessibility
Keys
Esc
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The frames: a string or { src, alt }
urlList accepts both variants, mixed together included:
<GrImageViewer
v-model="open"
:url-list="[
{ src: '/photos/roof.jpg', alt: 'The roof from above' },
'/photos/plan.png',
]"
/>A string sets the address only — the alt of such a frame is empty. The component cannot
invent the text on the consumer’s behalf, but neither must it substitute the name of the
file silently: for a blind user “roof.jpg” is not a description. The object form is the way
to give a description where there is one.
The alt also arrives in the toolbar slot (src, alt in the slot props) — the toolbar
can show a caption next to the frame.
The accessible name and announcing a change of frame
The layer declares itself a dialog, so it always has a name: from the locale
(gr.imageViewer.label) or from the ariaLabel prop. A dialog without a name is a
violation of aria-dialog-name.
A change of frame goes into the shared live region of the package (announcer.md):
“Image 2 of 5” (gr.imageViewer.position). On opening the position is not announced — the
name of the dialog will say it.
The region lies outside the viewer, although the viewer covers the page with inert.
That works thanks to the exemption by data-gr-live-region in the layer stack: inert
throws a subtree out of the accessibility tree, and a region inside it would fall silent.
The showProgress counter stays purely visual: it duplicates the same information for the
eye.
Zoom, panning and gestures
The wheel and the trackpad gesture zoom into the point under the cursor rather than from the centre: otherwise reaching the corner of an enlarged frame would require a separate drag.
The offset of the frame is bounded by its own overflow — as far as the picture has come out of the viewing area, that far it can be dragged. Dragging an image into the void and losing it is impossible, and on returning to a scale of 1 the offset resets itself. A rotation by 90° swaps the axes, and the bounds are computed from the rotated frame.
An enlarged frame can always be dragged — the draggable prop permits dragging a fitted one
as well (which matters for rotated frames that come off the screen even without zoom).
The gestures have two owners, and that is visible in the behaviour. The mouse and the
pen are run by the shared useDragGesture primitive: the listeners are
on window, so the release is caught both outside the picture and when the frame changed in
the middle of the gesture. Touch is run by a tracker of its own — it has a multi-finger
model: a pinch with two fingers, a swipe for paging, and on an enlarged frame a single
gesture drags.
An interrupted gesture (the browser took the pointer — a system gesture, a call, the loss of the window) ends the dragging leaving the frame where it was brought to, and does not count as a swipe: an interrupted gesture does not page.
The nominal scale against the real one
scale is nominal: one means “the frame is fitted into the window” (object-contain) rather
than the natural size. For a photograph of 4752 px fitted into a window about 1000 px wide, a
nominal 100% is a real 21%: at such a scale the pixels are not visible at all.
Hence two different buttons in the toolbar. “100%” resets the transformation and returns the
fitted frame; “1:1” (actions.zoomToNatural) brings it to a real 100% — a pixel of the
picture per pixel of the screen. The consumer does not have to compute that themselves,
although the slot does give the data for the computation (naturalWidth, renderedWidth,
realScale).
The maxScale ceiling applies to “1:1” as well: it limits the zoom deliberately, and the
natural size is no reason to bypass it. The default maxScale: 5 does not reach a real 100%
for a large frame (the nominal zoom needed is naturalWidth / renderedWidth at scale = 1,
which for photographs is 5–12×), so for large images the ceiling is set with a prop. The
reverse works too: if the frame is smaller than its place on the screen, “1:1” will shrink it —
with an eye on minScale.
The touch gestures work on the same pointer events:
| Gesture | What it does |
|---|---|
| Two fingers | The scale by the distance between them, the anchor is the midpoint |
| A finger on a fitted frame, horizontally | Paging through the frames |
| A finger on an enlarged frame | Dragging |
The swipe threshold is 60px and a predominance of the horizontal: a vertical movement must
not page, the user was aiming elsewhere. The picture is declared touch-action: none,
otherwise the browser takes the gesture for itself and moves the page instead of zooming.
Downloading
showDownload adds a button to the toolbar: it downloads the current frame
(<a download>) and emits download with { src, alt, index } — for analytics.
A cross-origin address will not always be downloaded by the browser: the download
attribute is ignored for a foreign origin, and the file opens in a new tab. Where signed
links or a request of your own are needed, the button is switched off
(showDownload: false) and one of your own is put into the #toolbar-actions slot — it
receives the same slot props, actions.download included.
Preloading the neighbouring frames
The neighbours of the current frame are warmed up only for an open viewer: a closed one on a page does not pull two full-size images merely because it is there. On a change of frame and on closing, unfinished loads are aborted — otherwise quick paging through a gallery piles up requests nobody needs any more.
Changing the list on the fly
The viewer holds on to the frame, not to the index. If the next page of the gallery has
loaded, the open image stays on the screen together with its scale and rotation, even if it
has shifted by position. If the current frame has disappeared from the list, the index is
clamped to the bounds rather than reset to initialIndex: throwing the user back to the
first image on every load is not allowed.
The layer
--gr-z-modal, as in the other modal overlays. zIndexVar replaces the layer variable with
one of your own — the same escape hatch as in useFloating and GrLoading. The component
does not accept a raw number: the layer is set by the scale, see
../z-index.md.
The exact value is calc(var(<token>) + depth): the viewer takes its own depth among the
open modal layers from the stack, so it ends up above a window by rendering as well, not only
by Esc. With zIndexVar that works the same way — the depth is added to the substituted
variable.
Esc, inert for the lower layers and the return of focus go through the shared stack
(useOverlayLayer), so a viewer on top of a modal closes itself rather than the modal.
The chrome tokens
The panel, the buttons and the scrim are coloured with per-component tokens:
| Token | What it colours |
|---|---|
--gr-image-viewer-scrim | the scrim under the image |
--gr-image-viewer-chrome-bg | the background of the buttons and of the toolbar |
--gr-image-viewer-chrome-bg-hover | the background of a button under the cursor |
--gr-image-viewer-chrome-bg-soft | the soft highlight of the toolbar buttons |
--gr-image-viewer-chrome-fg | the icons and the text of the chrome |
--gr-image-viewer-chrome-fg-muted | the text of the empty state |
--gr-image-viewer-chrome-brd | the borders and the separators |
--gr-image-viewer-ring | the focus ring |
The values are the same in the light and the dark theme on purpose: next to a photograph a light panel argues with the content and highlights the edges of the frame. The tokens exist not for changing the theme but so that the consumer can recolour the chrome to fit their product.
The imperative API
<GrImageViewer ref="viewer" v-model="open" :url-list="images" />viewer.value gives away close, prev, next, zoomIn, zoomOut, zoomToNatural,
reset, rotateLeft, rotateRight — the same set the toolbar slot receives.
Opening is not among them: it belongs to v-model, and a second way in would put the state
out of sync with the model.
The events
| Event | When |
|---|---|
update:modelValue | opening/closing |
close | closing |
change | another frame is shown, the argument is the new index |
rotate | a rotation, the argument is the accumulated angle in degrees |
Props in this example