Skip to content

Customization

The common changes are ordered by scope: restyling, replacing a button or the full-size image, adding your own controls, and translating the interface. Each starts with the smallest available change; full composition is rarely needed.

You want to… Reach for Rebuild the UI?
Different colours, radii, spacing CSS custom properties No
Restyle one part heavily data-* selectors No
Swap a button’s element or icon asChild / children No
Add, remove, or reorder buttons Compose DefaultContent yourself Yes, but from the same parts
Replace the image component or observe loading renderImage No
Add a keyboard shortcut Extensions No
Translate the interface labels No

The quickest change. The preset stylesheet reads everything from custom properties, so overriding them restyles the whole viewer without touching a selector.

:root {
--riv-accent: #0f766e; /* focus rings, active control state */
--riv-accent-surface: #ccfbf1; /* active control background */
--riv-chrome: #ffffff; /* header and toolbar surface */
--riv-stage: #f4f4f5; /* area behind the image */
--riv-ink: #18181b; /* primary text and icons */
--riv-ink-muted: #71717a; /* secondary text */
--riv-line: #e4e4e7; /* borders and separators */
--riv-hover: #f4f4f5; /* control hover background */
--riv-radius: 12px; /* dialog corner radius */
--riv-radius-toolbar: 10px; /* toolbar corner radius */
}

Set them on :root or on the dialog itself — not on an ancestor class. The viewer portals to document.body, so a rule scoped to a wrapper class never reaches it. That is also why dark mode is wired to prefers-color-scheme plus an explicit data-riv-theme="light" | "dark" on :root or the dialog, rather than to a .dark class on some container.

Every part exposes its identity and state as data-*, so you can target it with plain CSS or Tailwind variants without knowing any class names.

/* One specific control */
[data-image-view-control='actual-size'] {
font-variant-numeric: tabular-nums;
}
/* State, not a class */
[data-image-view-control][data-active] {
outline: 2px solid var(--riv-accent);
}
[data-image-view-control][data-boundary] {
opacity: 0.35;
}
/* Regions */
[data-image-view-region='toolbar'] {
backdrop-filter: blur(8px);
}
[data-image-view-stage][data-phase='dismissing'] {
cursor: grabbing;
}
// The same hooks in Tailwind
<ImageView.ActualSize className="data-[active]:bg-teal-100 data-[boundary]:opacity-40" />

The full set: data-image-view (dialog), -stage, -viewport, -track, -slide, -trigger, -title, -counter, -loading, -error, -thumbnails, -thumb, -region="header|toolbar|footer", and -control="close|prev|next|zoom-in|zoom-out|rotate-left|rotate-right|fit|actual-size|download|retry". State: data-active, data-boundary, data-disabled, data-current, data-phase, data-state, data-closing.

To change a control’s element — to use your design system’s button, or wrap it in a tooltip — pass asChild and provide the element yourself. The control merges its behaviour, aria-label, and data-* onto your child instead of rendering its own <button>.

import { Button } from '@/components/ui/button'
function ZoomButton() {
return (
<ImageView.ZoomIn asChild>
<Button variant="ghost" size="icon">
<PlusIcon />
</Button>
</ImageView.ZoomIn>
)
}

To change only the icon or text, pass children — no asChild needed:

<ImageView.Close>
<XIcon /> Dismiss
</ImageView.Close>

To add, remove, or reorder buttons, write the shell yourself. This is the same set of parts DefaultContent uses — there is no private assembly underneath it — so you are not giving anything up by opting in.

The main entry’s rule is: if Group finds an <ImageView.Content> (or a <DefaultContent>) among its direct children, it adds nothing of its own. Keep that composition boundary direct rather than hiding it inside a wrapper. For the smallest custom bundle, import the headless namespace below; its Group never imports or appends the preset.

import { ImageView } from 'react-img-view/primitives'
function CustomViewer({ images }) {
return (
<ImageView.Group images={images}>
{images.map((image, i) => (
<ImageView key={image.src} index={i} {...image}>
<img src={image.src} alt={image.alt} />
</ImageView>
))}
<ImageView.Content className="riv-dialog">
<ImageView.Header className="riv-header">
<ImageView.Close>Close</ImageView.Close>
<ImageView.Title />
<ImageView.Counter />
<span className="flex-1" />
{/* A button of your own, right next to the built-ins */}
<button onClick={() => window.print()}>Print</button>
<ImageView.Download>Download</ImageView.Download>
</ImageView.Header>
<ImageView.Stage className="riv-stage">
<ImageView.Image />
<ImageView.Prev></ImageView.Prev>
<ImageView.Next></ImageView.Next>
<ImageView.Loading>Loading…</ImageView.Loading>
<ImageView.Error>
{({ retry }) => (
<div>
<p>This image couldn&apos;t be loaded</p>
<button onClick={retry}>Retry</button>
</div>
)}
</ImageView.Error>
{/* Rotate dropped, zoom kept, order changed — nothing is mandatory */}
<ImageView.Toolbar className="riv-toolbar">
<ImageView.ZoomOut />
<ImageView.ZoomIn />
<ImageView.FitToWindow />
<ImageView.ActualSize />
</ImageView.Toolbar>
</ImageView.Stage>
</ImageView.Content>
</ImageView.Group>
)
}
Woman holding a vintage camera
camera-portrait.webp
Steam rising across a green geothermal landscape
geothermal-landscape.webp
Woman writing in a journal in a sunlit forest
forest-journal.webp
Star-filled night sky above a forest
starry-night.webp

useViewer() works anywhere inside Group, so a custom control is just a button that calls the API.

import { useViewer } from 'react-img-view'
function ZoomReadout() {
const viewer = useViewer()
return <span>{Math.round(viewer.scale * 100)}%</span>
}
function PrintButton() {
const viewer = useViewer()
return (
<button onClick={() => window.print()} disabled={viewer.status !== 'ready'}>
Print
</button>
)
}

To open from outside Group — from a table row’s “View” button, for example — call the imperative API directly:

import { ImagePreview } from 'react-img-view/imperative'
function ViewButton({ images }) {
return <Button onClick={() => ImagePreview.open({ images, index: 2 })}>View image 3</Button>
}

Use controlled state instead when the surrounding component needs to read or persist the open state and current index:

const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
<ImageView.Group
images={images}
open={open}
index={index}
onOpenChange={setOpen}
onIndexChange={setIndex}
/>
<Button onClick={() => { setIndex(2); setOpen(true); }}>View image 3</Button>

Use renderImage to add attributes such as referrerPolicy or srcSet to the full-size image, render your own image component, or observe load and error events. The library supplies the imageProps required for sizing, loading, retry, and transforms; the final <img> must receive them.

const renderImage = ({ item, imageProps }) => (
<picture>
<source srcSet={`${item.src}.webp`} type="image/webp" />
<img
{...imageProps}
referrerPolicy="no-referrer"
onLoad={(event) => {
imageProps.onLoad?.(event)
analytics.track('image_loaded', { src: item.src })
}}
/>
</picture>
)
<ImageView.Group images={images}>
{/* triggers */}
<ImageView.DefaultContent renderImage={renderImage} />
</ImageView.Group>

When overriding onLoad or onError, call the original handler from imageProps as shown; dropping it disconnects loading state and retry. The same renderer can be passed to the single-image <ImageView> entry, ImageView.Image, or ImagePreview.open().

For keyboard behaviour that composition cannot reach, pass an extension. Returning true marks the event consumed so the built-in handling is skipped.

const pageWithSpace = {
name: 'space-pages',
onKeyDown(event, api) {
if (event.key !== ' ') return
if (event.shiftKey) api.prev()
else api.next()
return true // handled; don't fall through
},
}
function Viewer({ images }) {
return <ImageView.Group images={images} extensions={[pageWithSpace]} />
}

Pointer gestures stay owned by Stage’s tested state machine. Anything that needs to render belongs in the tree as a child instead — extensions are a narrow keyboard escape hatch, not a plugin system.

Every user-facing string comes from one record. Most surface only as aria-label on an otherwise icon-only control — errorTitle is the one the default UI still renders as visible text. Left alone, Group uses stable English defaults. Pass labels explicitly; it merges field by field so overriding one leaves the rest alone.

<ImageView.Group
images={images}
labels={{ close: 'Fermer', download: 'Télécharger', zoomIn: 'Agrandir' }}
/>

For Simplified Chinese, import the complete locale instead of repeating keys:

import zhCN from 'react-img-view/locales/zh-CN'
function ChineseViewer({ images }) {
return <ImageView.Group images={images} labels={zhCN} />
}
Woman holding a vintage camera
camera-portrait.webp
Steam rising across a green geothermal landscape
geothermal-landscape.webp

Custom controls should read the same set with useLabels(), so an app translates one place rather than two:

import { useLabels } from 'react-img-view'
function MyClose() {
const labels = useLabels()
return <ImageView.Close aria-label={labels.close}>{labels.close}</ImageView.Close>
}

See ViewerLabels for the full list of keys.