Comic Viewer Tailwind CSS Demo

Default stylesheet demo

A page that is a whole spread

Page 4 of this document is one landscape image covering both halves of a printed sheet, which layout: "spread" declares the way EPUB fixed layout does with rendition:page-spread-center. The rail reports it as data-page-layout="spread" on the page slot and the page, and the data-[page-layout=spread] variants on the page slot are what give it back the half of the page set the double-page basis would otherwise take.

It counts as both halves of its sheet, so page 3 is left facing the blank half before it and pages 5 and 6 pair again exactly as they would in print. Narrow the window until the reader falls back to a single page: the spread is still shown whole, fitted to the width of the viewport.

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";67import { basicSamplePages, spreadPlaceholder } from "#components/sample-pages";89const navigationButtonClassName =10  "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";1112/**13 * The page-turn buttons over the page. The logical `start-3` and `end-3` place14 * them on the sides the reading direction gives them, and the icons point the15 * way a page turn goes, which the viewer context reports.16 */17const PageNavigation = () => {18  const { readingDirection } = ComicViewer.useViewerContext();19  const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;20  const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;2122  return (23    <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">24      <ComicViewer.PreviousPageButton25        className={`start-3 ${navigationButtonClassName}`}26      >27        <PreviousIcon aria-hidden="true" className="size-6" />28      </ComicViewer.PreviousPageButton>29      <ComicViewer.NextPageButton30        className={`end-3 ${navigationButtonClassName}`}31      >32        <NextIcon aria-hidden="true" className="size-6" />33      </ComicViewer.NextPageButton>34    </ComicViewer.PageNavigation>35  );36};3738/**39 * One landscape image covering both halves of a printed sheet, which40 * `layout: "spread"` declares the way EPUB fixed layout does with41 * `rendition:page-spread-center`. Its width and height are the measurements of42 * the sheet, so the viewer knows the image is twice as wide as a page before it43 * has loaded.44 */45const spreadPage: ViewerPage = {46  height: 1000,47  id: "page-4",48  layout: "spread",49  placeholder: spreadPlaceholder,50  src: "/basic-pages/spread.png",51  title: "Page 4",52  width: 1600,53};5455// Page 4 of this document is the spread, so its pages run 1, 2, and 3, then the56// spread, and then 5 through 8. The spread counts as both halves of its sheet,57// which leaves page 3 facing the blank half before it.58const spreadPageDocument: ViewerPage[] = [59  ...basicSamplePages.slice(0, 3),60  spreadPage,61  ...basicSamplePages.slice(4, 8),62];6364/**65 * Nothing about this reader is particular to the spread. A page declared as one66 * keeps both halves of its page set, which the `data-[page-layout=spread]`67 * variants on the page slot give back to it.68 */69export const SpreadPageReader = () => (70  <ComicViewer.Root71    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"72    pages={spreadPageDocument}73  >74    {/* The track is three viewports wide and turns pages by translating itself.75        The page set, the slot, and the page each align the half of the spread they76        hold through the `data-page-side` attribute the rail reports. */}77    <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">78      <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))]">79        {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}80        <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))]">81          {/* A page that is a whole spread on its own keeps both halves of the set,82              so the double-page basis it would otherwise take is given back to it. */}83          <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">84            <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">85              {/* The page fit mode the viewport reports is what sizes the image. */}86              <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" />87            </ComicViewer.ViewportPage>88          </ComicViewer.ViewportPageSlot>89        </ComicViewer.ViewportPageSet>90      </ComicViewer.ViewportTrack>91    </ComicViewer.Viewport>92    {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets93        on it while the reader controls are at rest. */}94    <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">95      {/* The slider reports the share of the document its thumb rests at as96            `--pcv-page-progress-fill`, which paints the part of the track behind it,97            and the fill runs the way the reader turns pages. */}98      <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">99        <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" />100        <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />101      </ComicViewer.PageProgress>102    </ComicViewer.Toolbar>103    <PageNavigation />104  </ComicViewer.Root>105);