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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| open / onCloseboolean / () => void | boolean / () => void | Controlled. Escape and the close button call `onClose`. |
| pages{ id, label, content }[] | { id, label, content }[] | Each `content` is laid out at `size` and scaled. |
| kind"deck" | "pages" | "deck" | "pages"= "deck" | One slide at a time, or a scrolling document. |
| size{ w, h } | { w, h }= 960×540 / 680×962 | The natural page size — 16:9 for a deck, A4 for pages. |
| title / metaReactNode | ReactNode | The header, and a line under it. |
| actionsReactNode | ReactNode | Header controls before close — download, share. |
| Scaledcomponent | component | The 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.
"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>
);
}