Comic Viewer Tailwind CSS Demo

Default stylesheet demo

Chapter 1: The long way round

Written and drawn by the Publira sample studio.

Before you read

This chapter is published a week ahead of its free release. Please keep the pages to yourself until then.

Why am I seeing this?

Early access comes with the membership this chapter was opened with.

Pages around the chapter

StartPage and EndPage put content of your own at the two ends of the reading sequence, as many of them at each end as you write, and none of them is counted as a page: the progress reads Pages 1-2 of 7 on the first spread of the chapter. This reader counts the spreads from the first start page with spreadStartIndex={-2}, so the two opening pages face each other. They carry the same data-page-side attribute as a page, so the same variants keep them on the half of the spread they belong to, and a data-slot-page attribute of their own for the place each of them takes in its slot. Each one is laid out as a sheet the shape of a page, with its card floating at the centre.

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 Link from "next/link";7import type { PropsWithChildren } from "react";89/**10 * A start or an end page carries the same `data-page-side` attribute as a page,11 * so the same variants keep it on the half of the spread it belongs to.12 */13const slotPageClassName =14  "flex h-full w-full items-center justify-center overflow-auto data-[page-side=left]:justify-end data-[page-side=right]:justify-start";1516/**17 * Lays an extra page out as a page of the book: a sheet the shape of a page,18 * with the card of the notice floating at the centre of it.19 */20const SlotSheet = ({ children }: PropsWithChildren) => (21  <div className="flex aspect-[4/5] h-full max-w-full items-center justify-center bg-[#19191f] bg-[radial-gradient(120%_80%_at_50%_0%,rgb(255_255_255/0.09),transparent_62%)] p-6 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]">22    <div className="flex w-full max-w-sm flex-col gap-3 rounded-xl border border-slate-100/20 bg-slate-950/80 p-6 text-sm leading-6 shadow-2xl shadow-black/50 backdrop-blur-sm">23      {children}24    </div>25  </div>26);2728const navigationButtonClassName =29  "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";3031/**32 * The page-turn buttons over the page. The logical `start-3` and `end-3` place33 * them on the sides the reading direction gives them, and the icons point the34 * way a page turn goes, which the viewer context reports.35 */36const PageNavigation = () => {37  const { readingDirection } = ComicViewer.useViewerContext();38  const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;39  const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;4041  return (42    <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">43      <ComicViewer.PreviousPageButton44        className={`start-3 ${navigationButtonClassName}`}45      >46        <PreviousIcon aria-hidden="true" className="size-6" />47      </ComicViewer.PreviousPageButton>48      <ComicViewer.NextPageButton49        className={`end-3 ${navigationButtonClassName}`}50      >51        <NextIcon aria-hidden="true" className="size-6" />52      </ComicViewer.NextPageButton>53    </ComicViewer.PageNavigation>54  );55};5657interface SlotReaderProps {58  pages: readonly ViewerPage[];59}6061/**62 * A reader that opens on front matter and closes on back matter. A StartPage or63 * an EndPage is composed into the viewer root as a child, a slot holds as many64 * of them as it is written with, and none of them is counted as a page.65 * Counting the spreads from the first start page pairs the two of them with66 * each other, and leaves the document paired as it was.67 */68export const SlotReader = ({ pages }: SlotReaderProps) => (69  <ComicViewer.Root70    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"71    pages={pages}72    spreadStartIndex={-2}73  >74    <ComicViewer.StartPage className={slotPageClassName}>75      <SlotSheet>76        <h3 className="text-base font-semibold">Before you read</h3>77        <p>78          This chapter is published a week ahead of its free release. Please79          keep the pages to yourself until then.80        </p>81        <details>82          <summary className="cursor-pointer font-semibold">83            Why am I seeing this?84          </summary>85          <p className="mt-2">86            Early access comes with the membership this chapter was opened with.87          </p>88        </details>89      </SlotSheet>90    </ComicViewer.StartPage>9192    <ComicViewer.StartPage className={slotPageClassName}>93      <SlotSheet>94        <h3 className="text-base font-semibold">95          Chapter 1: The long way round96        </h3>97        <p>Written and drawn by the Publira sample studio.</p>98      </SlotSheet>99    </ComicViewer.StartPage>100101    <ComicViewer.EndPage className={slotPageClassName}>102      <SlotSheet>103        <h3 className="text-base font-semibold">Next chapter</h3>104        <p>Chapter 2 is ready to read.</p>105        <Link className="font-semibold underline" href="/">106          Back to the first chapter107        </Link>108      </SlotSheet>109    </ComicViewer.EndPage>110111    <ComicViewer.EndPage className={slotPageClassName}>112      <SlotSheet>113        <h3 className="text-base font-semibold">More from this series</h3>114        <p>115          Three side stories follow the same cast between the chapters of the116          main run.117        </p>118        <Link className="font-semibold underline" href="/recipes/progress">119          A longer chapter to scrub through120        </Link>121      </SlotSheet>122    </ComicViewer.EndPage>123    {/* The track is three viewports wide and turns pages by translating itself.124        The page set, the slot, and the page each align the half of the spread they125        hold through the `data-page-side` attribute the rail reports. */}126    <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">127      <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))]">128        {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}129        <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))]">130          {/* A page that is a whole spread on its own keeps both halves of the set,131              so the double-page basis it would otherwise take is given back to it. */}132          <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">133            <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">134              {/* The page fit mode the viewport reports is what sizes the image. */}135              <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" />136            </ComicViewer.ViewportPage>137          </ComicViewer.ViewportPageSlot>138        </ComicViewer.ViewportPageSet>139      </ComicViewer.ViewportTrack>140    </ComicViewer.Viewport>141    {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets142        on it while the reader controls are at rest. */}143    <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">144      {/* The slider reports the share of the document its thumb rests at as145            `--pcv-page-progress-fill`, which paints the part of the track behind it,146            and the fill runs the way the reader turns pages. */}147      <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">148        <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" />149        <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />150      </ComicViewer.PageProgress>151    </ComicViewer.Toolbar>152    <PageNavigation />153  </ComicViewer.Root>154);