Comic Viewer Tailwind CSS Demo

Default stylesheet demo
Pages available7 of 21
Metadata requests0
Page images decoded0
Chapters loaded1 of 3

Lazy page metadata

This reader is never handed a page list. It is given the number of pages it holds and a resolvePage function, and it asks for the metadata of a page only as the reader comes near it. The imaginary endpoint behind this demo waits more than a second before answering, so the placeholder a page shows while its metadata is on its way stays on screen long enough to see. It is ViewportPendingPage carrying the utilities this demo gives it through renderPendingPage, in the place the page will take in the spread.

Only the pages within pageResolveOverscan of the current one are asked for, four on either side by default, which is why the request count starts at five rather than at the length of the document. A request for a page the reader leaves far behind is aborted through the AbortSignal it was given, and its metadata is forgotten once the page is further away than both that window and the pages the viewport can still render, so a page returned to much later is resolved again and a signed URL that has expired in the meantime is reissued.

Page images follow a window of their own. imagePreloadSpreads is 1 here, so the viewer fetches and decodes one spread beyond the ones it can render on either side of the reader. Those loads are queued behind the spread on screen and dropped when the reader moves away from them, so the count of decoded pages runs ahead of the three spreads the viewport holds without the current one waiting for them.

The document also grows as it is read. It starts as the first chapter of seven pages, and onEndReached appends the next chapter once the reader comes within two pages of the end, which the page count and the reading progress follow immediately.

Source code

1"use client";23import * as ComicViewer from "@publira/comic-viewer";4import type { PageResolveContext, ViewerPage } from "@publira/comic-viewer";5import { ChevronLeft, ChevronRight } from "lucide-react";6import { useCallback, useMemo, useState } from "react";78/** How many pages one chapter of the sample document holds. */9const CHAPTER_LENGTH = 7;10/** How long the imaginary page-metadata endpoint takes to answer. */11const PAGE_RESOLVE_DELAY_MS = 1200;12/** How long the imaginary chapter index takes to answer. */13const CHAPTER_LOAD_DELAY_MS = 400;1415/** Resolves after the delay, or rejects as soon as the request is aborted. */16const wait = (delayMs: number, signal?: AbortSignal): Promise<void> =>17  // eslint-disable-next-line promise/avoid-new -- A delay is only reachable through the timer callback API.18  new Promise((resolve, reject) => {19    const timeout = setTimeout(resolve, delayMs);2021    signal?.addEventListener(22      "abort",23      () => {24        clearTimeout(timeout);25        reject(signal.reason);26      },27      { once: true }28    );29  });3031export interface LazyChapters {32  /** How many chapters the whole document holds. */33  chapterCount: number;34  isLoadingChapter: boolean;35  /** How many chapters have been appended to the viewer so far. */36  loadedChapterCount: number;37  /** Appends the next chapter. Passed to the viewer as `onEndReached`. */38  loadNextChapter: () => void;39  /** The number of pages the viewer knows about, resolved or not. */40  pageCount: number;41  /** How many page-metadata requests have been answered. */42  requestCount: number;43  resolvePage: (44    index: number,45    context: PageResolveContext46  ) => Promise<ViewerPage | undefined>;47}4849/**50 * Stands in for a paginated backend: it hands the viewer one chapter of page51 * count at a time, and answers for the metadata of a single page only once the52 * viewer asks for it.53 */54export const useLazyChapters = (pages: readonly ViewerPage[]): LazyChapters => {55  const [pageCount, setPageCount] = useState(() =>56    Math.min(CHAPTER_LENGTH, pages.length)57  );58  const [isLoadingChapter, setIsLoadingChapter] = useState(false);59  const [requestCount, setRequestCount] = useState(0);6061  const resolvePage = useCallback(62    async (63      index: number,64      { signal }: PageResolveContext65    ): Promise<ViewerPage | undefined> => {66      await wait(PAGE_RESOLVE_DELAY_MS, signal);67      setRequestCount((count) => count + 1);6869      // An endpoint that signs its URLs would sign this one here, which is why70      // the viewer asks again for a page the reader comes back to much later.71      return pages[index];72    },73    [pages]74  );7576  const loadNextChapter = useCallback(() => {77    if (pageCount >= pages.length) {78      return;79    }8081    setIsLoadingChapter(true);82    void (async () => {83      await wait(CHAPTER_LOAD_DELAY_MS);84      setPageCount((count) => Math.min(pages.length, count + CHAPTER_LENGTH));85      setIsLoadingChapter(false);86    })();87  }, [pageCount, pages.length]);8889  return {90    chapterCount: Math.ceil(pages.length / CHAPTER_LENGTH),91    isLoadingChapter,92    loadNextChapter,93    loadedChapterCount: Math.ceil(pageCount / CHAPTER_LENGTH),94    pageCount,95    requestCount,96    resolvePage,97  };98};99100const statClassName =101  "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";102103interface LazyStatProps {104  label: string;105  value: string;106}107108const LazyStat = ({ label, value }: LazyStatProps) => (109  <div className={statClassName}>110    <span className="text-xs text-slate-600 dark:text-slate-400">{label}</span>111    <output aria-label={label} className="text-lg font-semibold tabular-nums">112      {value}113    </output>114  </div>115);116117const navigationButtonClassName =118  "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";119120/**121 * The page-turn buttons over the page. The logical `start-3` and `end-3` place122 * them on the sides the reading direction gives them, and the icons point the123 * way a page turn goes, which the viewer context reports.124 */125const PageNavigation = () => {126  const { readingDirection } = ComicViewer.useViewerContext();127  const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;128  const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;129130  return (131    <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">132      <ComicViewer.PreviousPageButton133        className={`start-3 ${navigationButtonClassName}`}134      >135        <PreviousIcon aria-hidden="true" className="size-6" />136      </ComicViewer.PreviousPageButton>137      <ComicViewer.NextPageButton138        className={`end-3 ${navigationButtonClassName}`}139      >140        <NextIcon aria-hidden="true" className="size-6" />141      </ComicViewer.NextPageButton>142    </ComicViewer.PageNavigation>143  );144};145146// Lazily resolved metadata is what this demo is about, so the page standing in147// for one still on its way is styled here like every other part of the reader.148const renderPendingPage = () => (149  <ComicViewer.ViewportPendingPage className="h-full w-full animate-pulse bg-slate-900" />150);151152/** How many spreads beyond the viewport this demo loads ahead of the reader. */153const IMAGE_PRELOAD_SPREADS = 1;154155interface LazyReaderProps {156  pages: readonly ViewerPage[];157}158159/** Renders a reader whose page metadata arrives as the reader reaches it. */160export const LazyReader = ({ pages }: LazyReaderProps) => {161  const [decodedImageCount, setDecodedImageCount] = useState(0);162  // The hook reads each decoded page without returning one of its own, so the163  // viewer keeps drawing the image it decoded.164  const plugins = useMemo(165    () => [166      ComicViewer.definePlugin({167        afterDecode: () => {168          setDecodedImageCount((count) => count + 1);169        },170        name: "count-decoded-pages",171      }),172    ],173    []174  );175  const {176    chapterCount,177    isLoadingChapter,178    loadNextChapter,179    loadedChapterCount,180    pageCount,181    requestCount,182    resolvePage,183  } = useLazyChapters(pages);184185  return (186    <>187      <section188        aria-label="Comic reader"189        className="aspect-[4/5] min-h-96 w-full md:aspect-[8/5]"190      >191        <ComicViewer.Root192          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"193          pageCount={pageCount}194          imagePreloadSpreads={IMAGE_PRELOAD_SPREADS}195          onEndReached={loadNextChapter}196          plugins={plugins}197          resolvePage={resolvePage}198        >199          {/* The track is three viewports wide and turns pages by translating itself.200              The page set, the slot, and the page each align the half of the spread they201              hold through the `data-page-side` attribute the rail reports. */}202          <ComicViewer.Viewport203            renderPendingPage={renderPendingPage}204            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"205          >206            <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))]">207              {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}208              <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))]">209                {/* A page that is a whole spread on its own keeps both halves of the set,210                    so the double-page basis it would otherwise take is given back to it. */}211                <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">212                  <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">213                    {/* The page fit mode the viewport reports is what sizes the image. */}214                    <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" />215                  </ComicViewer.ViewportPage>216                </ComicViewer.ViewportPageSlot>217              </ComicViewer.ViewportPageSet>218            </ComicViewer.ViewportTrack>219          </ComicViewer.Viewport>220          {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets221              on it while the reader controls are at rest. */}222          <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">223            {/* The slider reports the share of the document its thumb rests at as224                  `--pcv-page-progress-fill`, which paints the part of the track behind it,225                  and the fill runs the way the reader turns pages. */}226            <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">227              <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" />228              <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />229            </ComicViewer.PageProgress>230          </ComicViewer.Toolbar>231          <PageNavigation />232        </ComicViewer.Root>233      </section>234      <div className="flex flex-wrap gap-3">235        <LazyStat236          label="Pages available"237          value={`${pageCount} of ${pages.length}`}238        />239        <LazyStat label="Metadata requests" value={String(requestCount)} />240        <LazyStat241          label="Page images decoded"242          value={String(decodedImageCount)}243        />244        <LazyStat245          label="Chapters loaded"246          value={247            isLoadingChapter248              ? "Loading…"249              : `${loadedChapterCount} of ${chapterCount}`250          }251        />252      </div>253    </>254  );255};