Image Viewer renders a static image preview and opens a full-screen viewer for pan, zoom, rotation, and flip interactions.
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"Keyboard and Pointer Interactions
Focus the full-screen viewport to use keyboard interactions.
- Arrow keys pan the image by nudgeStep, which defaults to
1pixel. Shiftwith an Arrow key uses nudgeStepShift, which defaults to10pixels.CtrlorCmdwith an Arrow key usesnudgeStepCtrl, which defaults to50pixels.+or=zooms in.-or_zooms out.- The mouse wheel zooms around the pointer position.
- Pinch gestures zoom and pan the image on touch devices.
- Press
Escapeor click the backdrop to close the viewer.
Closing resets zoom, rotation, flip, and pan.
Examples
Simple
Use src and alt to render an image preview with the default popup, zoom, and reset controls.
<ImageViewer
alt="Scenic view of a lake in the mountains"
src="/images/nature-2.avif"
/>
Composite
Supply src and alt to <ImageViewer.Root>, then provide the composite parts to render the viewer.
import type {ReactElement} from "react"
import {ButtonGroup} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerCompositeDemo(): ReactElement {
return (
<ImageViewer.Root
alt="Scenic view of a lake in the mountains"
className="w-full"
src="/images/nature-2.avif"
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<ButtonGroup aria-label="Image controls" size="sm">
<ImageViewer.ZoomOutButton />
<ImageViewer.ZoomInButton />
<ImageViewer.ResetButton />
</ButtonGroup>
</ImageViewer.FloatingPortal>
</ImageViewer.Root>
)
}Controlled Open State
Use open and onOpenChange for controlled popup visibility. These props follow our controlled state pattern.
import {useState, type ReactElement} from "react"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerControlledOpenDemo(): ReactElement {
const [open, setOpen] = useState(false)
return (
<ImageViewer
alt="Scenic view of a lake in the mountains"
className="w-full"
onOpenChange={setOpen}
open={open}
src="/images/nature-2.avif"
/>
)
}Controlled Zoom
Set zoom and update it in onZoomChange. These props follow our controlled state pattern.
import {type ReactElement, useState} from "react"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerControlledZoomDemo(): ReactElement {
const [zoom, setZoom] = useState(1)
return (
<div className="flex w-full flex-col items-center gap-4">
<ImageViewer
alt="Scenic view of a lake in the mountains"
className="w-full"
onZoomChange={(details) => setZoom(details.zoom)}
src="/images/nature-2.avif"
zoom={zoom}
/>
<output>{zoom.toFixed(1)}x</output>
</div>
)
}Zoom Limits
Set minZoom and maxZoom to constrain the zoom range.
Zoom is limited to 0.5x through 2x.
import {type ReactElement, useState} from "react"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
const minZoom = 0.5
const maxZoom = 2
export function ImageViewerZoomLimitsDemo(): ReactElement {
const [zoom, setZoom] = useState(1)
return (
<div className="flex w-full flex-col items-center gap-4">
<ImageViewer
alt="Scenic view of a lake in the mountains"
className="w-full"
maxZoom={maxZoom}
minZoom={minZoom}
onZoomChange={(details) => setZoom(details.zoom)}
src="/images/nature-2.avif"
zoom={zoom}
/>
<output>{zoom.toFixed(1)}x</output>
<p className="text-body-sm text-neutral-secondary">
Zoom is limited to {minZoom}x through {maxZoom}x.
</p>
</div>
)
}Rotation
Set rotation and update it in onRotationChange. These props follow our controlled state pattern.
import {type ReactElement, useState} from "react"
import {RotateCcw, RotateCw} from "lucide-react"
import {ButtonGroup, IconButton} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerRotationDemo(): ReactElement {
const [rotation, setRotation] = useState(0)
return (
<div className="flex w-full flex-col items-center gap-4">
<ImageViewer.Root
alt="Scenic view of a lake in the mountains"
className="w-full"
onRotationChange={(details) => setRotation(details.rotation)}
rotation={rotation}
src="/images/nature-2.avif"
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<div className="flex items-center gap-2">
<ButtonGroup
aria-label="Rotation controls"
emphasis="white-persistent"
size="sm"
variant="outline"
>
<IconButton
aria-label="Rotate counter-clockwise"
icon={RotateCcw}
onClick={() =>
setRotation((currentRotation) => currentRotation - 90)
}
/>
<IconButton
aria-label="Rotate clockwise"
icon={RotateCw}
onClick={() =>
setRotation((currentRotation) => currentRotation + 90)
}
/>
</ButtonGroup>
<output>{rotation}°</output>
</div>
</ImageViewer.FloatingPortal>
</ImageViewer.Root>
</div>
)
}Flip
Set flip and update it in onFlipChange. These props follow our controlled state pattern.
import {type ReactElement, useState} from "react"
import {FlipHorizontal, FlipVertical} from "lucide-react"
import {ButtonGroup, IconButton} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerFlipDemo(): ReactElement {
const [flip, setFlip] = useState({horizontal: false, vertical: false})
return (
<div className="flex w-full flex-col items-center gap-4">
<ImageViewer.Root
alt="Scenic view of a lake in the mountains"
className="w-full"
flip={flip}
onFlipChange={(details) => setFlip(details.flip)}
src="/images/nature-2.avif"
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<ButtonGroup
aria-label="Flip controls"
emphasis="white-persistent"
size="sm"
>
<IconButton
aria-label="Flip horizontally"
emphasis="white-persistent"
icon={FlipHorizontal}
onClick={() =>
setFlip((currentFlip) => ({
...currentFlip,
horizontal: !currentFlip.horizontal,
}))
}
variant={flip.horizontal ? "fill" : "outline"}
/>
<IconButton
aria-label="Flip vertically"
icon={FlipVertical}
onClick={() =>
setFlip((currentFlip) => ({
...currentFlip,
vertical: !currentFlip.vertical,
}))
}
variant={flip.vertical ? "fill" : "outline"}
/>
</ButtonGroup>
</ImageViewer.FloatingPortal>
</ImageViewer.Root>
</div>
)
}Reset
Use useImageViewer with <RootProvider> when external code owns the viewer state and popup composition.
import type {ReactElement} from "react"
import {
FlipHorizontal,
RefreshCw,
RotateCcw,
RotateCw,
ZoomIn,
ZoomOut,
} from "lucide-react"
import {useImageViewer} from "@qualcomm-ui/react-core/image-viewer"
import {ButtonGroup, IconButton} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerResetDemo(): ReactElement {
const imageViewer = useImageViewer()
return (
<ImageViewer.RootProvider
alt="Scenic view of a lake in the mountains"
className="w-full"
src="/images/nature-2.avif"
value={imageViewer}
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<ButtonGroup
aria-label="Image controls"
emphasis="white-persistent"
size="sm"
variant="outline"
>
<IconButton
aria-label="Zoom out"
icon={ZoomOut}
onClick={() => imageViewer.zoomBy(-0.1)}
/>
<IconButton
aria-label="Zoom in"
icon={ZoomIn}
onClick={() => imageViewer.zoomBy(0.1)}
/>
<IconButton
aria-label="Rotate counter-clockwise"
icon={RotateCcw}
onClick={() => imageViewer.rotateBy(-90)}
/>
<IconButton
aria-label="Rotate clockwise"
icon={RotateCw}
onClick={() => imageViewer.rotateBy(90)}
/>
<IconButton
aria-label="Flip horizontally"
icon={FlipHorizontal}
onClick={() => imageViewer.flipHorizontally()}
/>
<IconButton
aria-label="Reset image"
icon={RefreshCw}
onClick={() => imageViewer.reset()}
/>
</ButtonGroup>
</ImageViewer.FloatingPortal>
</ImageViewer.RootProvider>
)
}Events
Use onZoomChange to track the current zoom level.
import {type ReactElement, useState} from "react"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerEventsDemo(): ReactElement {
const [zoom, setZoom] = useState(1)
return (
<div className="flex w-full flex-col gap-4">
<ImageViewer
alt="Scenic view of a lake in the mountains"
className="w-full"
onZoomChange={(details) => setZoom(details.zoom)}
src="/images/nature-2.avif"
/>
<output>Current zoom: {zoom.toFixed(2)}x</output>
</div>
)
}Context
Use <ImageViewer.Context> to access the viewer API inside the root.
import type {ReactElement} from "react"
import {ZoomIn, ZoomOut} from "lucide-react"
import {ButtonGroup, IconButton} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerContextDemo(): ReactElement {
return (
<ImageViewer.Root
alt="Scenic view of a lake in the mountains"
className="w-full"
src="/images/nature-2.avif"
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<ImageViewer.Context>
{(context) => (
<div className="flex items-center justify-center gap-2">
<ButtonGroup
aria-label="Zoom controls"
emphasis="white-persistent"
size="sm"
variant="outline"
>
<IconButton
aria-label="Zoom out"
icon={ZoomOut}
onClick={() => context.zoomBy(-0.1)}
/>
<IconButton
aria-label="Zoom in"
icon={ZoomIn}
onClick={() => context.zoomBy(0.1)}
/>
</ButtonGroup>
<output>{context.zoom.toFixed(1)}x</output>
</div>
)}
</ImageViewer.Context>
</ImageViewer.FloatingPortal>
</ImageViewer.Root>
)
}Root Provider
Use useImageViewer with <ImageViewer.RootProvider> when external code owns the viewer API.
import type {ReactElement} from "react"
import {ZoomIn, ZoomOut} from "lucide-react"
import {useImageViewer} from "@qualcomm-ui/react-core/image-viewer"
import {ButtonGroup, IconButton} from "@qualcomm-ui/react/button"
import {ImageViewer} from "@qualcomm-ui/react/image-viewer"
export function ImageViewerRootProviderDemo(): ReactElement {
const imageViewer = useImageViewer()
return (
<ImageViewer.RootProvider
alt="Scenic view of a lake in the mountains"
className="w-full"
src="/images/nature-2.avif"
value={imageViewer}
>
<ImageViewer.Trigger className="block w-full">
<ImageViewer.StaticImage className="mx-auto block max-w-full" />
</ImageViewer.Trigger>
<ImageViewer.FloatingPortal>
<ImageViewer.CloseButton />
<ImageViewer.Viewport>
<ImageViewer.ViewportImage />
</ImageViewer.Viewport>
<div className="flex items-center justify-center gap-2">
<ButtonGroup
aria-label="Zoom controls"
emphasis="white-persistent"
size="sm"
variant="outline"
>
<IconButton
aria-label="Zoom out"
icon={ZoomOut}
onClick={() => imageViewer.setZoom(imageViewer.zoom - 0.1)}
/>
<IconButton
aria-label="Zoom in"
icon={ZoomIn}
onClick={() => imageViewer.setZoom(imageViewer.zoom + 0.1)}
/>
</ButtonGroup>
<output>{imageViewer.zoom.toFixed(1)}x</output>
</div>
</ImageViewer.FloatingPortal>
</ImageViewer.RootProvider>
)
}API
<ImageViewer>
The simple API accepts the props from ImageViewer.Root except children. It renders the preview, popup, zoom controls, and reset control. Use the composite API for custom popup contents.
neverButtonGroupPropsImageViewerApi
The viewer API is available from <ImageViewer.Context> and useImageViewer.
Use open and setOpen to read and update popup visibility when external code owns the viewer API.
{
horizontal: boolean
vertical: boolean
}
(
value?: boolean,
) => void
(
value?: boolean,
) => void
() => HTMLElement
() =>
| Node
| ShadowRoot
| Document
() => HTMLElement
() => void
{
height: number
width: number
}
{
x: number
y: number
}
booleanbooleanboolean() => void
(
degrees: number,
) => void
rotateBy(90) // Rotate 90 degrees clockwise
rotateBy(-90) // Rotate 90 degrees counter-clockwise
number(
flip: Partial<{
horizontal: boolean
vertical: boolean
}>,
) => void
(status: {
ready: boolean
size: {
height: number
width: number
}
}) => void
(
open: boolean,
) => void
(
rotation: number,
) => void
(
zoom: number,
) => void
{
listLabel?: string
}
{
bottom: number
height: number
left: number
right: number
top: number
width: number
}
number(
delta: number,
) => void
zoomBy(0.1) // Zoom in by 10%
zoomBy(-0.1) // Zoom out by 10%
Composite API
<ImageViewer.Root>
src and alt are defaults that either image can override.
string{
horizontal: boolean
vertical: boolean
}
booleannumbernumber'ltr' | 'rtl'
{
horizontal: boolean
vertical: boolean
}
() =>
| Node
| ShadowRoot
| Document
Partial<{
root: string
viewport: string
viewportImage: string
}>
numbernumbernumbernumbernumber(details: {
flip: {
horizontal: boolean
vertical: boolean
}
}) => void
(
open: boolean,
) => void
(details: {
rotation: number
}) => void
(details: {
zoom: number
}) => void
boolean| ReactElement
| ((
props: object,
) => ReactElement)
numberstring{
listLabel?: string
}
numbernumbernumberclassName'qui-image-viewer__root'data-image-viewer-part'root'data-panningdata-pinchstyle<ImageViewer.Trigger>
| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__trigger'data-dialog-part'trigger'data-state| 'open'
| 'closed'
<ImageViewer.RootProvider>
string| ReactElement
| ((
props: object,
) => ReactElement)
stringShortcuts
<ImageViewer.FloatingPortal>
ImageViewer.FloatingPortal combines the portal, backdrop, positioner, and content elements.
import {Portal} from "@qualcomm-ui/react-core/portal"
;<Portal {...props}>
<ImageViewer.Backdrop {...backdropProps} />
<ImageViewer.Positioner {...positionerProps}>
<ImageViewer.Content {...contentProps}>{children}</ImageViewer.Content>
</ImageViewer.Positioner>
</Portal>| HTMLElement
| ShadowRoot
| RefObject<
HTMLElement | ShadowRoot
>
booleanPopup API
<ImageViewer.Backdrop>
string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__backdrop'data-dialog-part'backdrop'data-state| 'open'
| 'closed'
hiddenboolean<ImageViewer.Positioner>
string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__positioner'data-dialog-part'positioner'style<ImageViewer.Content>
string| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__content'data-dialog-part'content'data-state| 'open'
| 'closed'
hiddenbooleantabIndex-1
<ImageViewer.CloseButton>
| 'default'
| 'compact'
booleantrue, pointer/focus
events are blocked, and the component is visually dimmed.| 'neutral'
| 'primary'
| 'danger'
| 'persistent-white'
| 'persistent-black'
| 'inverse'
| 'white-persistent'
| 'black-persistent'
| LucideIcon
| ReactNode
| ReactElement
| ((
props: object,
) => ReactElement)
| 'square'
| 'rounded'
| 'sm'
| 'md'
| 'lg'
| 'fill'
| 'ghost'
| 'outline'
className'qui-image-viewer__close-trigger'data-dialog-part'close-trigger'Viewer API
<ImageViewer.Viewport>
| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__viewport'data-image-viewer-part'viewport'data-ownedbystringdata-panningdata-readystyletabIndex0<ImageViewer.StaticImage>
| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__static-image'<ImageViewer.ViewportImage>
| ReactElement
| ((
props: object,
) => ReactElement)
className'qui-image-viewer__viewport-image'data-flip-horizontaldata-flip-verticaldata-image-viewer-part'viewport-image'data-ownedbystringdata-readystyle<ImageViewer.Context>
<ImageViewer.ZoomInButton>
| 'default'
| 'compact'
booleantrue, pointer/focus
events are blocked, and the component is visually dimmed.| 'neutral'
| 'primary'
| 'danger'
| 'persistent-white'
| 'persistent-black'
| 'inverse'
| 'white-persistent'
| 'black-persistent'
| LucideIcon
| ReactNode
| ReactElement
| ((
props: object,
) => ReactElement)
| 'square'
| 'rounded'
| 'sm'
| 'md'
| 'lg'
| 'fill'
| 'ghost'
| 'outline'
className'qui-image-viewer__zoom-in-trigger'data-image-viewer-part'zoom-in-trigger'<ImageViewer.ZoomOutButton>
| 'default'
| 'compact'
booleantrue, pointer/focus
events are blocked, and the component is visually dimmed.| 'neutral'
| 'primary'
| 'danger'
| 'persistent-white'
| 'persistent-black'
| 'inverse'
| 'white-persistent'
| 'black-persistent'
| LucideIcon
| ReactNode
| ReactElement
| ((
props: object,
) => ReactElement)
| 'square'
| 'rounded'
| 'sm'
| 'md'
| 'lg'
| 'fill'
| 'ghost'
| 'outline'
className'qui-image-viewer__zoom-out-trigger'data-image-viewer-part'zoom-out-trigger'<ImageViewer.ResetButton>
| 'default'
| 'compact'
booleantrue, pointer/focus
events are blocked, and the component is visually dimmed.| 'neutral'
| 'primary'
| 'danger'
| 'persistent-white'
| 'persistent-black'
| 'inverse'
| 'white-persistent'
| 'black-persistent'
| LucideIcon
| ReactNode
| ReactElement
| ((
props: object,
) => ReactElement)
| 'square'
| 'rounded'
| 'sm'
| 'md'
| 'lg'
| 'fill'
| 'ghost'
| 'outline'
className'qui-image-viewer__reset-trigger'data-image-viewer-part'reset-trigger'