Comic Viewer Tailwind CSS Demo

Default stylesheet demo

Labels in the reader’s language

Tap the page and the controls read in Japanese. The viewer ships English labels, and every one of them can be replaced where the component is placed: aria-label names the progress, the navigation group, and the page-turn buttons, and a format function names the pages on screen.

PageStatus and PageProgressSlider take the same format, which receives the pages on screen and returns their label. The status shows it, and the slider announces it through aria-valuetext, at rest and while a drag is under way, so a screen reader hears the page under the thumb in the words the toolbar shows. The lang attribute around the reader tells it which voice to use.

Source code

1"use client";23import * as ComicViewer from "@publira/comic-viewer";4import type { PageStatusValue, ViewerPage } from "@publira/comic-viewer";5import { ChevronLeft, ChevronRight } from "lucide-react";67/**8 * Labels the pages on screen in Japanese. The status and the slider share it,9 * so what the toolbar shows and what a screen reader hears always agree.10 */11const formatPageStatus = ({12  firstPage,13  lastPage,14  pageCount,15  slot,16}: PageStatusValue) => {17  if (firstPage === 0) {18    if (slot === undefined) {19      return "ページがありません";20    }2122    return slot === "start" ? "巻頭ページ" : "巻末ページ";23  }2425  return firstPage === lastPage26    ? `${firstPage} / ${pageCount} ページ`27    : `${firstPage}〜${lastPage} / ${pageCount} ページ`;28};2930const navigationButtonClassName =31  "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";3233/**34 * The page-turn buttons over the page. The logical `start-3` and `end-3` place35 * them on the sides the reading direction gives them, and the icons point the36 * way a page turn goes, which the viewer context reports. Each of them is37 * named in Japanese, as is the group that holds them.38 */39const PageNavigation = () => {40  const { readingDirection } = ComicViewer.useViewerContext();41  const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;42  const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;4344  return (45    <ComicViewer.PageNavigation46      aria-label="ページ送り"47      className="pointer-events-none absolute inset-0 z-10 transition duration-150 ease-out aria-hidden:translate-y-2 aria-hidden:opacity-0"48    >49      <ComicViewer.PreviousPageButton50        aria-label="前のページ"51        className={`start-3 ${navigationButtonClassName}`}52      >53        <PreviousIcon aria-hidden="true" className="size-6" />54      </ComicViewer.PreviousPageButton>55      <ComicViewer.NextPageButton56        aria-label="次のページ"57        className={`end-3 ${navigationButtonClassName}`}58      >59        <NextIcon aria-hidden="true" className="size-6" />60      </ComicViewer.NextPageButton>61    </ComicViewer.PageNavigation>62  );63};6465interface LocalizedReaderProps {66  pages: readonly ViewerPage[];67}6869/**70 * A reader whose controls speak Japanese. The `lang` around it tells a screen71 * reader which voice to read the labels in.72 */73export const LocalizedReader = ({ pages }: LocalizedReaderProps) => (74  <div className="h-full" lang="ja">75    <ComicViewer.Root76      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"77      pages={pages}78    >79      {/* The track is three viewports wide and turns pages by translating itself.80        The page set, the slot, and the page each align the half of the spread they81        hold through the `data-page-side` attribute the rail reports. */}82      <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">83        <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))]">84          {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}85          <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))]">86            {/* A page that is a whole spread on its own keeps both halves of the set,87              so the double-page basis it would otherwise take is given back to it. */}88            <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">89              <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">90                {/* The page fit mode the viewport reports is what sizes the image. */}91                <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" />92              </ComicViewer.ViewportPage>93            </ComicViewer.ViewportPageSlot>94          </ComicViewer.ViewportPageSet>95        </ComicViewer.ViewportTrack>96      </ComicViewer.Viewport>97      {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets98        on it while the reader controls are at rest. */}99      <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">100        {/* The slider reports the share of the document its thumb rests at as101            `--pcv-page-progress-fill`, which paints the part of the track behind it,102            and the fill runs the way the reader turns pages. */}103        <ComicViewer.PageProgress104          aria-label="読書の進み具合"105          className="mx-auto min-w-0 shrink basis-3/5"106        >107          <ComicViewer.PageProgressSlider108            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            format={formatPageStatus}110          />111          <ComicViewer.PageStatus112            className="mt-1.5 block text-center text-sm text-slate-100"113            format={formatPageStatus}114          />115        </ComicViewer.PageProgress>116      </ComicViewer.Toolbar>117      <PageNavigation />118    </ComicViewer.Root>119  </div>120);