← All components

DocumentViewer

A full-screen viewer with a thumbnail rail, for a deck you step through or pages you scroll.

The idea

Pages are laid out at their natural size and scaled with a transform, never CSS zoom — browsers clamp zoomed text at their minimum size, so a zoomed thumbnail keeps full-size type. That also means a thumbnail is the page itself, not a lookalike. A deck steps with the arrow keys, each slide sliding in from the way you are going; a document scrolls, and the page taking most of the stage is the one the rail marks.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

DocumentViewer — usage11 lines
import { DocumentViewer } from "mihirmodi-ui";

<DocumentViewer
  open={!!doc}
  onClose={() => setDoc(null)}
  title={doc?.name}
  meta="Exported from a session"
  kind="deck"                    // or "pages" for a scrolling A4 document
  pages={slides.map((s) => ({ id: s.id, label: s.title, content: <Slide {...s} /> }))}
  actions={<DownloadButton />}
/>

Props

PropNotes
open / onCloseboolean / () => voidControlled. Escape and the close button call `onClose`.
pages{ id, label, content }[]Each `content` is laid out at `size` and scaled.
kind"deck" | "pages"One slide at a time, or a scrolling document.
size{ w, h }The natural page size — 16:9 for a deck, A4 for pages.
title / metaReactNodeThe header, and a line under it.
actionsReactNodeHeader controls before close — download, share.
ScaledcomponentThe same transform scaling on its own, for previews and thumbnails.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/media/DocumentViewer.tsx276 lines
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CloseIcon } from "../../lib/glyph.js";
import { CLOSE, EASE_GEIST } from "../../lib/motion.js";

export type DocumentPage = { id: string; label: string; content: ReactNode };

/**
 * Children laid out at `width` and drawn at `scale`, taking their scaled size in
 * the flow so neighbours and scrolling see the real box. A transform, never CSS
 * `zoom`: browsers clamp zoomed text at their minimum font size, so a zoomed
 * thumbnail keeps full-size type.
 */
export function Scaled({ scale, width, children, className = "" }: { scale: number; width: number; children: ReactNode; className?: string }) {
  const inner = useRef<HTMLDivElement>(null);
  const [h, setH] = useState(0);
  useEffect(() => {
    const el = inner.current;
    if (!el) return;
    setH(el.offsetHeight);
    const ro = new ResizeObserver(() => setH(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return (
    <div className={`relative ${className}`} style={{ width: width * scale, height: h ? h * scale : undefined }}>
      <div ref={inner} style={{ width, transform: `scale(${scale})`, transformOrigin: "top left" }}>
        {children}
      </div>
    </div>
  );
}

export type DocumentViewerProps = {
  open: boolean;
  onClose: () => void;
  title: ReactNode;
  /** A line under the title — where it came from, when. */
  meta?: ReactNode;
  pages: DocumentPage[];
  /** `deck` shows one slide at a time; `pages` scrolls a document top to bottom. */
  kind?: "deck" | "pages";
  /** The natural size a page is laid out at. Defaults to 960×540 for a deck and 680×962 (A4) for pages. */
  size?: { w: number; h: number };
  /** Header controls before the close button — download, share. */
  actions?: ReactNode;
  startAt?: number;
};

const ZOOMS = [0.5, 0.67, 0.8, 1, 1.25, 1.5];

/**
 * A full-screen glass viewer for a rendered document: a rail of thumbnails, and
 * the document at a zoom you control, fitted to the stage by default.
 *
 * A deck shows one slide at a time and steps with the arrow keys, each slide
 * sliding 10px in the direction you are going; a document scrolls, and the page
 * taking up most of the stage is the one the rail marks. Pages are laid out at
 * their natural size and scaled with a transform, so a thumbnail is the page, not
 * a lookalike. Escape closes it, and focus lands on the dialog itself rather than
 * on its first button, so no tooltip opens with it.
 */
export function DocumentViewer({ open, onClose, title, meta, pages, kind = "deck", size, actions, startAt = 0 }: DocumentViewerProps) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return createPortal(
    <LazyMotion features={domAnimation} strict>
      <AnimatePresence>
        {open && (
          <Viewer key="viewer" onClose={onClose} title={title} meta={meta} pages={pages} kind={kind} size={size ?? (kind === "deck" ? { w: 960, h: 540 } : { w: 680, h: 962 })} actions={actions} startAt={startAt} />
        )}
      </AnimatePresence>
    </LazyMotion>,
    document.body
  );
}

function Viewer({ onClose, title, meta, pages, kind, size, actions, startAt }: Omit<DocumentViewerProps, "open" | "size"> & { size: { w: number; h: number } }) {
  const reduce = useReducedMotion();
  const deck = kind === "deck";
  const [cur, setCur] = useState(Math.min(startAt ?? 0, pages.length - 1));
  const [dir, setDir] = useState(1);
  const [zoom, setZoom] = useState<number | null>(null);
  const [fit, setFit] = useState(1);
  const stage = useRef<HTMLDivElement>(null);
  const pageEls = useRef<(HTMLDivElement | null)[]>([]);
  const dialog = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    // Fit: the page fills the stage's width (and, for a slide, its height too),
    // never past its natural size.
    const measure = () => {
      const byWidth = (el.clientWidth - 64) / size.w;
      const byHeight = deck ? (el.clientHeight - 120) / size.h : Infinity;
      setFit(Math.min(1, Math.max(0.3, Math.min(byWidth, byHeight))));
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [size.w, size.h, deck]);
  const z = zoom ?? fit;

  const go = (i: number) => {
    const n = Math.max(0, Math.min(pages.length - 1, i));
    setDir(n >= cur ? 1 : -1);
    setCur(n);
    if (!deck) pageEls.current[n]?.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
  };

  useEffect(() => {
    dialog.current?.focus({ preventScroll: true });
  }, []);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.target as Element).closest?.("input, textarea")) return;
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowRight" || e.key === "PageDown" || (deck && e.key === "ArrowDown")) go(cur + 1);
      else if (e.key === "ArrowLeft" || e.key === "PageUp" || (deck && e.key === "ArrowUp")) go(cur - 1);
      else if (e.key === "Home") go(0);
      else if (e.key === "End") go(pages.length - 1);
      else return;
      e.preventDefault();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cur, deck, pages.length, onClose]);

  // A scrolling document: the page taking most of the stage is current.
  useEffect(() => {
    if (deck || !stage.current) return;
    const ratios = new Map<number, number>();
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => ratios.set(Number((e.target as HTMLElement).dataset.page), e.intersectionRatio));
        let best = 0, bestR = -1;
        ratios.forEach((r, i) => {
          if (r > bestR) { bestR = r; best = i; }
        });
        setCur(best);
      },
      { root: stage.current, threshold: [0, 0.2, 0.4, 0.6, 0.8, 1] }
    );
    pageEls.current.forEach((el) => el && io.observe(el));
    return () => io.disconnect();
  }, [deck, pages.length, z]);

  const stepZoom = (d: number) => {
    const i = ZOOMS.findIndex((v) => v >= z - 0.001);
    const at = i === -1 ? ZOOMS.length - 1 : Math.abs(ZOOMS[i] - z) < 0.001 ? i : d > 0 ? i - 1 : i;
    setZoom(ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, at + d))]);
  };

  const thumb = 150 / size.w;
  const btn = "flex h-7 items-center justify-center rounded-[7px] text-quiet transition-colors duration-150 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:opacity-40";

  return (
    <m.div
      ref={dialog}
      tabIndex={-1}
      role="dialog"
      aria-modal="true"
      aria-label={typeof title === "string" ? title : "Document preview"}
      className="glass-panel fixed inset-0 z-[70] flex flex-col outline-none"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0, transition: CLOSE }}
      transition={{ duration: 0.2 }}
    >
      <header className="flex h-14 shrink-0 items-center gap-3 border-b-[0.5px] border-gray-alpha-400 px-4">
        <div className="min-w-0">
          <h2 className="truncate text-[14px] font-medium text-gray-1000">{title}</h2>
          <p className="truncate text-[12px] text-faint">
            {meta}
            {meta ? " · " : ""}
            {pages.length} {deck ? "slides" : "pages"}
          </p>
        </div>
        <div className="ml-auto flex shrink-0 items-center gap-1">
          <div className="flex items-center rounded-[9px] border-[0.5px] border-gray-alpha-400 p-0.5" role="group" aria-label="Zoom">
            <button type="button" onClick={() => stepZoom(-1)} disabled={z <= ZOOMS[0] + 0.001} className={`${btn} w-7`} aria-label="Zoom out">
              <span aria-hidden className="block h-px w-3 bg-current" />
            </button>
            <button type="button" onClick={() => setZoom(zoom === null ? 1 : null)} className={`${btn} px-1.5 text-[12px] tabular-nums text-gray-900`} aria-label={zoom === null ? "Fitted, switch to actual size" : "Fit to the window"}>
              {Math.round(z * 100)}%
            </button>
            <button type="button" onClick={() => stepZoom(1)} disabled={z >= ZOOMS[ZOOMS.length - 1] - 0.001} className={`${btn} w-7`} aria-label="Zoom in">
              <span aria-hidden className="text-[15px] leading-none">+</span>
            </button>
          </div>
          {actions && <span aria-hidden className="mx-2 h-5 w-[0.5px] bg-gray-alpha-400" />}
          {actions}
          <button type="button" onClick={onClose} className="ml-1 flex h-8 w-8 items-center justify-center rounded-[10px] text-quiet transition-colors hover:bg-gray-alpha-200 hover:text-gray-1000" aria-label="Close preview">
            <CloseIcon />
          </button>
        </div>
      </header>

      <div className="flex min-h-0 flex-1">
        <nav aria-label={deck ? "Slides" : "Pages"} className="hidden w-[196px] shrink-0 overflow-y-auto border-r-[0.5px] border-gray-alpha-400 px-3 py-4 sm:block">
          <ol className="flex flex-col gap-3">
            {pages.map((p, i) => (
              <li key={p.id}>
                <button type="button" onClick={() => go(i)} aria-current={i === cur ? "true" : undefined} aria-label={`${deck ? "Slide" : "Page"} ${i + 1}, ${p.label}`} className="flex w-full items-start gap-2 text-left">
                  <span className={`w-4 shrink-0 pt-0.5 text-right text-[11px] tabular-nums ${i === cur ? "font-medium text-gray-1000" : "text-faint"}`}>{i + 1}</span>
                  <span
                    className={`block overflow-hidden rounded-[6px] bg-background-100 transition-shadow duration-150 ${
                      i === cur ? "shadow-[0_0_0_1.5px_var(--color-gray-1000)]" : "shadow-[0_0_0_0.5px_var(--color-gray-alpha-500)]"
                    }`}
                  >
                    <span aria-hidden className="pointer-events-none block">
                      <Scaled scale={thumb} width={size.w}>
                        <div style={{ height: size.h }}>{p.content}</div>
                      </Scaled>
                    </span>
                  </span>
                </button>
              </li>
            ))}
          </ol>
        </nav>

        <div ref={stage} className="relative min-w-0 flex-1 overflow-auto">
          {deck ? (
            <div className="flex min-h-full flex-col items-center justify-center gap-5 p-8">
              <AnimatePresence mode="wait" initial={false} custom={dir}>
                <m.div
                  key={pages[cur]?.id}
                  className="overflow-hidden rounded-[8px] bg-background-100 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_12px_32px_-12px_rgba(0,0,0,0.25)]"
                  initial={reduce ? { opacity: 0 } : { opacity: 0, x: 10 * dir }}
                  animate={{ opacity: 1, x: 0 }}
                  exit={reduce ? { opacity: 0, transition: CLOSE } : { opacity: 0, x: -10 * dir, transition: CLOSE }}
                  transition={reduce ? { duration: 0.15 } : { duration: 0.2, ease: EASE_GEIST }}
                >
                  <Scaled scale={z} width={size.w}>
                    <div style={{ height: size.h }}>{pages[cur]?.content}</div>
                  </Scaled>
                </m.div>
              </AnimatePresence>
              <div className="flex items-center gap-2" role="group" aria-label="Slide navigation">
                <button type="button" onClick={() => go(cur - 1)} disabled={cur === 0} aria-label="Previous slide" className="flex h-8 w-8 items-center justify-center rounded-full border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 disabled:opacity-40">
                  ‹
                </button>
                <span className="w-16 text-center text-[13px] tabular-nums text-muted" aria-live="polite">
                  {cur + 1} / {pages.length}
                </span>
                <button type="button" onClick={() => go(cur + 1)} disabled={cur === pages.length - 1} aria-label="Next slide" className="flex h-8 w-8 items-center justify-center rounded-full border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 disabled:opacity-40">
                  ›
                </button>
              </div>
            </div>
          ) : (
            <div className="flex flex-col items-center gap-6 px-8 py-8">
              {pages.map((p, i) => (
                <div key={p.id} className="overflow-hidden rounded-[6px] bg-background-100 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_12px_32px_-12px_rgba(0,0,0,0.2)]">
                  <Scaled scale={z} width={size.w}>
                    <div ref={(el) => { pageEls.current[i] = el; }} data-page={i} className="absolute inset-0 scroll-mt-8" aria-hidden />
                    <div style={{ height: size.h }}>{p.content}</div>
                  </Scaled>
                </div>
              ))}
            </div>
          )}
        </div>
      </div>
    </m.div>
  );
}