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.
How much do you need to change?
Section titled “How much do you need to change?”| 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 |
1. CSS custom properties
Section titled “1. CSS custom properties”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.
2. Data attributes
Section titled “2. Data attributes”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.
3. asChild
Section titled “3. asChild”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>4. Compose your own content
Section titled “4. Compose your own content”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'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> )}



Driving the viewer from your own UI
Section titled “Driving the viewer from your own UI”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>5. renderImage
Section titled “5. renderImage”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().
6. Extensions
Section titled “6. Extensions”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.
7. Labels
Section titled “7. Labels”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} />}

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.