Read the zoom scale back
Tap the page to reveal the toolbar, then pinch the page with two fingers — on a touch screen, or through the device toolbar of a browser’s developer tools. The readout follows the pinch, and “Reset zoom” returns the spread to the size its fit mode gives it. A double tap still snaps the page to fit-to-width, as it does in every other demo.
Neither control comes from the library. The viewer ships no zoom UI at all: it carries the scale as a read-only zoomScale on useViewerContext(), which is 1 while the spread rests at the size its fit mode gives it, and one write operation, resetZoom(), which clears the scale and the pan offset together. Everything above is built from those two, so a reader that wants a percentage badge, a disabled control, or a reset button does not have to read the --pcv-zoom-scale custom property back out of the DOM.
The scale belongs to the spread rather than to the reader. Turning the page returns it to 1, and so does picking another page-fit mode from the buttons at the end of the toolbar, because the gesture was made against the size the previous mode gave the page. Zooming in and out programmatically stays out of the API; the pinch and the double tap are what change the scale.
Source code
1"use client";23import * as ComicViewer from "@publira/comic-viewer";4import type { ViewerPage } from "@publira/comic-viewer";5import { ChevronLeft, ChevronRight } from "lucide-react";67/**8 * The reader-setting toggles of the library carry no look of their own, so this9 * demo dresses them as the pill the navigation buttons wear and lets10 * `aria-pressed` mark the setting the reader is on.11 */12const settingToggleClassName =13 "shrink-0 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-slate-100 outline-offset-2 outline-slate-100 transition hover:bg-black/80 focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50 aria-pressed:bg-slate-100/25";1415/**16 * Reports the zoom scale the viewer context carries and offers the reset it17 * exposes. Neither the readout nor the button is part of the library: both are18 * built from `zoomScale` and `resetZoom` alone.19 */20const ZoomStatus = () => {21 const { resetZoom, zoomScale } = ComicViewer.useViewerContext();2223 return (24 <div className="flex shrink-0 items-center gap-1">25 <output26 aria-label="Zoom scale"27 className="shrink-0 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-slate-100 tabular-nums"28 >29 {Math.round(zoomScale * 100)}%30 </output>31 <button32 className={settingToggleClassName}33 disabled={zoomScale === 1}34 onClick={resetZoom}35 type="button"36 >37 Reset zoom38 </button>39 </div>40 );41};4243const navigationButtonClassName =44 "pointer-events-auto absolute top-1/2 grid size-10 -translate-y-1/2 place-items-center rounded-full bg-black/60 p-0 text-slate-100 shadow-lg outline-offset-2 outline-slate-100 transition hover:bg-black/80 focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50";4546/**47 * The page-turn buttons over the page. The logical `start-3` and `end-3` place48 * them on the sides the reading direction gives them, and the icons point the49 * way a page turn goes, which the viewer context reports.50 */51const PageNavigation = () => {52 const { readingDirection } = ComicViewer.useViewerContext();53 const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;54 const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;5556 return (57 <ComicViewer.PageNavigation className="pointer-events-none absolute inset-0 z-10 transition duration-150 ease-out aria-hidden:translate-y-2 aria-hidden:opacity-0">58 <ComicViewer.PreviousPageButton59 className={`start-3 ${navigationButtonClassName}`}60 >61 <PreviousIcon aria-hidden="true" className="size-6" />62 </ComicViewer.PreviousPageButton>63 <ComicViewer.NextPageButton64 className={`end-3 ${navigationButtonClassName}`}65 >66 <NextIcon aria-hidden="true" className="size-6" />67 </ComicViewer.NextPageButton>68 </ComicViewer.PageNavigation>69 );70};7172interface ZoomReaderProps {73 pages: readonly ViewerPage[];74}7576/** A reader whose toolbar reads the zoom scale back out of the viewer. */77export const ZoomReader = ({ pages }: ZoomReaderProps) => (78 <ComicViewer.Root79 className="relative flex h-full min-h-0 w-full min-w-0 overflow-hidden rounded-xl bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/30"80 pages={pages}81 >82 {/* The track is three viewports wide and turns pages by translating itself.83 The page set, the slot, and the page each align the half of the spread they84 hold through the `data-page-side` attribute the rail reports. */}85 <ComicViewer.Viewport className="group/viewport relative flex min-h-0 min-w-0 flex-1 touch-pan-y overflow-hidden data-[pannable]:cursor-grab data-[pannable]:touch-none data-[panning]:cursor-grabbing">86 <ComicViewer.ViewportTrack className="flex h-full w-[300%] shrink-0 basis-[300%] [transform:translateX(calc(-33.3333%_+_var(--pcv-drag-offset)))] data-[dragging]:transition-none data-[transition-state=active]:transition-transform data-[transition-state=active]:duration-[260ms] data-[transition-state=active]:ease-out data-[transition-state=active]:data-[slide-direction=left]:[transform:translateX(calc(-66.6667%_+_var(--pcv-drag-offset)))] data-[transition-state=active]:data-[slide-direction=right]:[transform:translateX(var(--pcv-drag-offset))]">87 {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}88 <ComicViewer.ViewportPageSet className="flex h-full min-w-0 shrink-0 basis-1/3 data-[page-side=left]:justify-start data-[page-side=right]:justify-end data-[rail-slot=current]:[transform:translate(var(--pcv-pan-x,0)_var(--pcv-pan-y,0))_scale(var(--pcv-zoom-scale,1))]">89 {/* A page that is a whole spread on its own keeps both halves of the set,90 so the double-page basis it would otherwise take is given back to it. */}91 <ComicViewer.ViewportPageSlot className="flex min-w-0 flex-1 items-center justify-center data-[page-side=left]:justify-end data-[page-side=right]:justify-start data-[view-mode=double]:max-w-1/2 data-[view-mode=double]:basis-1/2 data-[view-mode=double]:data-[page-layout=spread]:max-w-full data-[view-mode=double]:data-[page-layout=spread]:basis-full">92 <ComicViewer.ViewportPage className="flex h-full w-full min-w-0 items-center justify-center data-[page-side=left]:justify-end data-[page-side=right]:justify-start">93 {/* The page fit mode the viewport reports is what sizes the image. */}94 <ComicViewer.PageCanvas className="h-full max-w-full bg-slate-900 object-contain transition-[filter] duration-150 group-data-[page-fit-mode=actual]/viewport:h-auto group-data-[page-fit-mode=actual]/viewport:w-auto group-data-[page-fit-mode=actual]/viewport:max-w-none group-data-[page-fit-mode=width]/viewport:h-auto group-data-[page-fit-mode=width]/viewport:w-full group-data-[page-fit-mode=width]/viewport:max-w-none data-[placeholder]:brightness-75 data-[placeholder]:saturate-75" />95 </ComicViewer.ViewportPage>96 </ComicViewer.ViewportPageSlot>97 </ComicViewer.ViewportPageSet>98 </ComicViewer.ViewportTrack>99 </ComicViewer.Viewport>100 {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets101 on it while the reader controls are at rest. */}102 <ComicViewer.Toolbar className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 bg-linear-to-t from-black/80 via-black/55 to-transparent px-3 pt-8 pb-3 transition duration-150 ease-out aria-hidden:translate-y-2 aria-hidden:opacity-0">103 <ZoomStatus />104 {/* The slider reports the share of the document its thumb rests at as105 `--pcv-page-progress-fill`, which paints the part of the track behind it,106 and the fill runs the way the reader turns pages. */}107 <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">108 <ComicViewer.PageProgressSlider className="block h-3.5 w-full cursor-pointer appearance-none bg-transparent p-0 outline-offset-4 outline-slate-100 [--pcv-page-progress-fill-direction:to_right] focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50 rtl:[--pcv-page-progress-fill-direction:to_left] [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-slate-100 [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-black/65 [&::-moz-range-track]:[background-image:linear-gradient(var(--pcv-page-progress-fill-direction),var(--color-slate-100)_var(--pcv-page-progress-fill),transparent_var(--pcv-page-progress-fill))] [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-black/65 [&::-webkit-slider-runnable-track]:[background-image:linear-gradient(var(--pcv-page-progress-fill-direction),var(--color-slate-100)_var(--pcv-page-progress-fill),transparent_var(--pcv-page-progress-fill))] [&::-webkit-slider-thumb]:-mt-[0.3125rem] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-slate-100" />109 <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />110 </ComicViewer.PageProgress>111 <div112 aria-label="Page fit"113 className="flex shrink-0 items-center gap-1"114 // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- A row of toggle buttons is a group of controls, not the form fieldset the semantic tags stand for.115 role="group"116 >117 {/* A toggle given no mode cycles through the three instead. */}118 <ComicViewer.PageFitModeToggle119 className={settingToggleClassName}120 mode="height"121 />122 <ComicViewer.PageFitModeToggle123 className={settingToggleClassName}124 mode="width"125 />126 <ComicViewer.PageFitModeToggle127 className={settingToggleClassName}128 mode="actual"129 />130 </div>131 </ComicViewer.Toolbar>132 <PageNavigation />133 </ComicViewer.Root>134);