The PPTX Viewer renders a PowerPoint deck the same way the PDF Viewer
renders pages: each slide is drawn to its own <canvas>. It parses the .pptx
(an Office Open XML zip) entirely in the browser with
pptxviewjs — a dependency-light,
canvas-rendering library — so there is no server, no upload, and no LibreOffice
conversion step.
It does continuous-scroll rendering, zoom, rotate, fit-to-width, and download, and exposes a per-slide overlay slot so you can draw bounding-box citations on top of the rendered slide.
It is built without useEffect: the deck loads with React's use() and Suspense,
slides render lazily via an IntersectionObserver (only slides near the viewport
hit the canvas), and renders are serialized so they never race.
Installation
pnpm dlx shadcn@latest add @retab/pptx-viewer
This pulls in pptxviewjs and its jszip peer (used both to unzip the deck and
to read the slide size). chart.js is also installed because the library imports
it statically; it is only exercised when a deck actually contains charts.
Usage
import { PptxViewer } from "@/components/ui/pptx-viewer"
export function Example() {
return <PptxViewer src="/deck.pptx" className="h-[600px]" />
}Bounding-box overlays
renderPageOverlay runs per slide and receives the rendered slide size, so
normalized boxes map with simple percentages:
<PptxViewer
src={url}
renderPageOverlay={({ pageNumber }) =>
citationsOnSlide(pageNumber).map((c) => (
<div
key={c.key}
className="absolute outline outline-2 outline-indigo-500"
style={{
left: `${c.bbox.left * 100}%`,
top: `${c.bbox.top * 100}%`,
width: `${c.bbox.width * 100}%`,
height: `${c.bbox.height * 100}%`,
}}
/>
))
}
/>How it performs
- Canvas rendering, client-side. Slides parse and rasterize in the browser —
no conversion service. The slide size is read straight from
presentation.xml, so every slide reserves its box before anything is drawn. - Lazy slides. Only slides near the viewport render; off-screen canvases are dropped, keeping a long deck bounded.
- Bitmap cache. Each rendered slide is cached as an
ImageBitmap(a capped LRU keyed by slide + zoom), so scroll-back and revisited zoom levels redraw instantly instead of re-running the renderer. - Serialized, cancellable renders. The underlying viewer holds one drawing context, so heavy renders are queued — never overlapping, never racing — and a queued render is skipped if its slide has already scrolled away, so fast-scrolling never wastes the queue on stale slides.
- Scroll-aware deferral. While the user is actively scrolling, a slide's
(expensive) first render is held until scrolling settles, so a fast fling stays
at frame rate instead of competing with on-the-fly renders. The initial paint, a
settled scroll, and already-cached slides all render immediately. Pass
eagerto render slides the moment they near the viewport instead.
Fidelity
pptxviewjs covers the common deck — text, shapes, tables, images, and charts —
but it is not a pixel-perfect PowerPoint clone. Exotic SmartArt, transitions, and
embedded media may render approximately or be omitted. For pixel-exact output,
convert to PDF server-side and use the PDF Viewer.
Props
The API mirrors the PDF Viewer — pageNumber is a
1-based slide index.
| Prop | Type | Description |
|---|---|---|
src | string | URL of the .pptx (same-origin or CORS-enabled). |
scale | number | Optional fixed scale; omit to fit slide width to the container. |
toolbar | boolean | Show the zoom/rotate/download toolbar. Defaults to true. |
downloadFileName | string | Filename for the download button. |
renderPageOverlay | (props) => ReactNode | Per-slide overlay; receives { pageNumber, width, height, scale, rotation }. |
onVisiblePageChange | (page: number) => void | Fires with the slide nearest the top of the viewport on scroll. |
onScrollProgressChange | (progress: number) => void | Fires with scroll progress in [0, 1]. |
header | ReactNode | Full-width strip below the toolbar (e.g. a legend). |
aside | ReactNode | Left rail alongside the scrolling slides (e.g. a thumbnail rail). |
eager | boolean | Render slides the moment they near the viewport, even mid-scroll, instead of deferring until scrolling settles. Defaults to false. |
bare | boolean | Drop the outer border/background so the viewer fills its container. |
className | string | Optional class on the viewer shell. |