Restoring your place…
Remember the reading position
Turn a few pages and reload this page: the reader opens where it was left. Nothing about that lives in the viewer, because where a position belongs — this browser, this session, or an account that follows a reader between devices — is a decision only the application can make. This demo keeps it in localStorage under a key that carries the document, so one origin can remember a position per document; swapping those two storage calls for requests to a backend is the whole difference between the two.
The position is held in the application and handed back to the viewer as currentIndex, with onIndexChange writing every page the reader turns to. A controlled index is what lets the “Start over” button return the reader to the first page as it clears the stored one; initialIndex is read once, when the viewer mounts, and would leave the reader where it was. An application writing to a network instead should debounce these calls, which arrive one per page turn.
Storage is an external store, and the hook reads it through useSyncExternalStore rather than while rendering: it does not exist on the server, and reading it during a render would make the markup React hydrates differ from the markup the server sent. The reader is held back for the one frame that takes, so that it opens on the stored page instead of fetching the first one and turning away from it. A browser that refuses storage outright, such as Safari in a private window, throws on the very first access; the position lives for as long as the tab does there, and an index stored for a document that has since grown shorter is clamped to the pages it now holds.
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";6import { useCallback, useSyncExternalStore } from "react";78/** Namespaced, so one origin can hold the progress of many documents. */9const getStorageKey = (documentId: string) =>10 `comic-viewer:reading-progress:${documentId}`;1112/**13 * Reads the position stored for a document, or `null` when there is none.14 *15 * Storage holds whatever an earlier version of an application wrote, so a16 * value that is not a page index is dropped rather than handed to the viewer,17 * and a browser set to block site data throws on the very first access.18 */19const readStoredIndex = (documentId: string): number | null => {20 let storedIndex: string | null;2122 try {23 storedIndex = window.localStorage.getItem(getStorageKey(documentId));24 } catch {25 return null;26 }2728 // An empty entry would read as zero, which is a page rather than nothing.29 if (storedIndex === null || storedIndex.trim() === "") {30 return null;31 }3233 const index = Number(storedIndex);3435 return Number.isInteger(index) && index >= 0 ? index : null;36};3738/**39 * The position of every document opened this session, and the store the hook40 * subscribes to. It is seeded from `localStorage` the first time a document is41 * asked for, and written through to it on every turn. Holding the session's42 * own copy is what lets a reader whose browser refuses storage — Safari in a43 * private window, or one set to block site data — keep turning pages: only the44 * position outliving the session is lost.45 */46const sessionProgress = new Map<string, number | null>();47const progressListeners = new Set<() => void>();4849const subscribeToProgress = (onProgressChange: () => void) => {50 progressListeners.add(onProgressChange);5152 return () => {53 progressListeners.delete(onProgressChange);54 };55};5657/** Reads through to storage once per document, then answers from the map. */58const getProgress = (documentId: string): number | null => {59 const sessionIndex = sessionProgress.get(documentId);6061 if (sessionIndex !== undefined) {62 return sessionIndex;63 }6465 const storedIndex = readStoredIndex(documentId);66 sessionProgress.set(documentId, storedIndex);6768 return storedIndex;69};7071/** Records a position, or forgets it when given `null`. */72const setProgress = (documentId: string, index: number | null) => {73 sessionProgress.set(documentId, index);7475 try {76 if (index === null) {77 window.localStorage.removeItem(getStorageKey(documentId));78 } else {79 window.localStorage.setItem(getStorageKey(documentId), String(index));80 }81 } catch {82 // The position still stands for this session; nothing else is recoverable.83 }8485 for (const listener of progressListeners) {86 listener();87 }88};8990/**91 * Rendering happens on the server too, where there is no storage to read. The92 * snapshot it renders from is its own value rather than `null`, so that a93 * position still on its way is never taken for a document that has none.94 */95const unrestored = Symbol("unrestored");9697/** A stored index, `null` where a document has none, or the server's own. */98type ProgressSnapshot = number | null | typeof unrestored;99100const getServerSnapshot = (): ProgressSnapshot => unrestored;101102interface ReadingProgress {103 /** The zero-based index to hand the viewer as `currentIndex`. */104 currentIndex: number;105 /**106 * Whether the stored position has been read back yet. It is false on the107 * server and through hydration, where there is no storage to ask.108 */109 isRestored: boolean;110 /** Forgets the stored position and opens the document at its first page. */111 resetProgress: () => void;112 /** Records the page the reader turned to. Passed as `onIndexChange`. */113 saveIndex: (index: number) => void;114 /** The remembered zero-based index, or `null` while none is remembered. */115 storedIndex: number | null;116}117118/**119 * Keeps the page a document was left on in `localStorage`, keyed by document.120 *121 * The viewer holds no opinion about persistence, because where a position122 * belongs — this browser, this session, or an account that follows a reader123 * between devices — is a decision only the application can make. Swapping the124 * storage calls in `getProgress` and `setProgress` for requests to a backend125 * is the whole difference between the two.126 */127export const useReadingProgress = (128 documentId: string,129 pageCount: number130): ReadingProgress => {131 // Storage is an external store, and reading it while rendering would make132 // the markup React hydrates differ from the markup the server sent. The133 // snapshot the server renders from is what tells a component that the134 // position has not been read back yet.135 const storedIndex = useSyncExternalStore<ProgressSnapshot>(136 subscribeToProgress,137 useCallback(() => getProgress(documentId), [documentId]),138 getServerSnapshot139 );140141 const saveIndex = useCallback(142 (index: number) => {143 setProgress(documentId, index);144 },145 [documentId]146 );147148 const resetProgress = useCallback(() => {149 setProgress(documentId, null);150 }, [documentId]);151152 const restoredIndex = typeof storedIndex === "number" ? storedIndex : null;153154 return {155 // A document can be shorter than it was when the position was stored, so156 // the index is held inside the document rather than trusted as it comes.157 currentIndex: Math.min(restoredIndex ?? 0, Math.max(0, pageCount - 1)),158 isRestored: storedIndex !== unrestored,159 resetProgress,160 saveIndex,161 storedIndex: restoredIndex,162 };163};164165const navigationButtonClassName =166 "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";167168/**169 * The page-turn buttons over the page. The logical `start-3` and `end-3` place170 * them on the sides the reading direction gives them, and the icons point the171 * way a page turn goes, which the viewer context reports.172 */173const PageNavigation = () => {174 const { readingDirection } = ComicViewer.useViewerContext();175 const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;176 const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;177178 return (179 <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">180 <ComicViewer.PreviousPageButton181 className={`start-3 ${navigationButtonClassName}`}182 >183 <PreviousIcon aria-hidden="true" className="size-6" />184 </ComicViewer.PreviousPageButton>185 <ComicViewer.NextPageButton186 className={`end-3 ${navigationButtonClassName}`}187 >188 <NextIcon aria-hidden="true" className="size-6" />189 </ComicViewer.NextPageButton>190 </ComicViewer.PageNavigation>191 );192};193194/** What the stored position is keyed by. A real one would be a slug or an ID. */195const documentId = "basic-sample";196197interface ProgressReaderProps {198 pages: readonly ViewerPage[];199}200201/**202 * Renders a reader that opens on the page it was last left on. The position is203 * held here and handed back to the viewer as `currentIndex`, with204 * `onIndexChange` writing every page the reader turns to.205 */206export const ProgressReader = ({ pages }: ProgressReaderProps) => {207 const { currentIndex, isRestored, resetProgress, saveIndex, storedIndex } =208 useReadingProgress(documentId, pages.length);209210 return (211 <>212 <section213 aria-label="Comic reader"214 className="aspect-[4/5] min-h-96 w-full md:aspect-[8/5]"215 >216 {isRestored ? (217 <ComicViewer.Root218 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"219 pages={pages}220 currentIndex={currentIndex}221 onIndexChange={saveIndex}222 >223 {/* The track is three viewports wide and turns pages by translating itself.224 The page set, the slot, and the page each align the half of the spread they225 hold through the `data-page-side` attribute the rail reports. */}226 <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">227 <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))]">228 {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}229 <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))]">230 {/* A page that is a whole spread on its own keeps both halves of the set,231 so the double-page basis it would otherwise take is given back to it. */}232 <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">233 <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">234 {/* The page fit mode the viewport reports is what sizes the image. */}235 <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" />236 </ComicViewer.ViewportPage>237 </ComicViewer.ViewportPageSlot>238 </ComicViewer.ViewportPageSet>239 </ComicViewer.ViewportTrack>240 </ComicViewer.Viewport>241 {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets242 on it while the reader controls are at rest. */}243 <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">244 {/* The slider reports the share of the document its thumb rests at as245 `--pcv-page-progress-fill`, which paints the part of the track behind it,246 and the fill runs the way the reader turns pages. */}247 <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">248 <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" />249 <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />250 </ComicViewer.PageProgress>251 </ComicViewer.Toolbar>252 <PageNavigation />253 </ComicViewer.Root>254 ) : (255 // The reader waits for the frame it takes to read storage, so that it256 // opens on the stored page instead of fetching the first one and257 // turning away from it.258 <p className="grid h-full w-full place-items-center rounded-xl bg-slate-950 text-sm text-slate-300">259 Restoring your place…260 </p>261 )}262 </section>263 <div className="flex flex-wrap items-center gap-3">264 <div className="flex flex-1 basis-48 flex-col gap-1 rounded-xl border border-slate-300 bg-white px-4 py-3 shadow-sm dark:border-slate-700 dark:bg-slate-900">265 <span className="text-xs text-slate-600 dark:text-slate-400">266 Saved position267 </span>268 <output269 aria-label="Saved position"270 className="text-lg font-semibold tabular-nums"271 >272 {storedIndex === null273 ? "Not saved yet"274 : `Page ${currentIndex + 1}`}275 </output>276 </div>277 {/* A controlled index is what lets this button return the reader to the278 first page as it clears the stored one. */}279 <button280 className="rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm font-semibold shadow-sm transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800"281 disabled={storedIndex === null}282 onClick={resetProgress}283 type="button"284 >285 Start over286 </button>287 </div>288 </>289 );290};