Michael profile

Real React, real PDF

Your slide engine doesn't need a PowerPoint exporter. The browser already is one. Here's how the export pipeline works in nopoint, and the small handful of things that go wrong if you don't think carefully.

The shape of the pipeline

import html2canvas from "html2canvas-pro"
import jsPDF from "jspdf"
 
const captureSlide = async (slideEl: HTMLElement, scale = 3) => {
  const canvas = await html2canvas(slideEl, { scale, backgroundColor: "#000" })
  return canvas
}
 
const exportDeck = async (slides: SlideDefinition[]) => {
  const pdf = new jsPDF({ orientation: "landscape", unit: "px", format: [1280, 720], compress: true })
  for (const [i, slide] of slides.entries()) {
    const canvas = await mountAndCapture(slide.component)
    if (i > 0) pdf.addPage([1280, 720], "landscape")
    pdf.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, 1280, 720)
  }
  pdf.save("deck.pdf")
}

Three calls — html2canvas, addPage, addImage. The interesting work is in mountAndCapture.

Why offscreen mounting

You can't just rasterise the slide that's currently on screen. The currently-visible slide has CSS transforms (it's centred in a letterbox), maybe a hover state, maybe it's animating in. You want a clean 1280×720 of the slide's intended visual.

So you mount each slide offscreen with react-dom/client's createRoot, into a hidden <div> sized to 1280×720, with class="dark deck-export" so dark-mode and group-hover variants don't break. Then you wait — 300ms is the conservative number — for fonts to load and animations to settle. Then you capture.

const mountAndCapture = async (Component: ComponentType) => {
  const host = document.createElement("div")
  host.className = "dark deck-export group"
  host.style.width = "1280px"
  host.style.height = "720px"
  host.style.position = "fixed"
  host.style.left = "-99999px"
  document.body.appendChild(host)
  const root = createRoot(host)
  root.render(<Component />)
  await new Promise(r => setTimeout(r, 300))
  const canvas = await html2canvas(host, { scale: 3 })
  root.unmount()
  host.remove()
  return canvas
}

300ms is a magic number. It's long enough for font-display: swap and most CSS animations. It's short enough that exporting a 30-slide IM doesn't take forever. Make it configurable per slide if you have a slide that legitimately needs longer.

Why html2canvas-pro and not html2canvas

The original html2canvas chokes on oklch() colors. Tailwind 4 ships oklch by default. The -pro fork handles them. There is no other reason. If you remember to use the fork, this never bites you. If you forget, your reds come out black.

The quality slider

Scale is the lever that matters. scale: 3 is the floor for Retina-looking PDFs. scale: 4 looks slightly better and doubles the file size. Below scale: 2 it looks like a screenshot of a screenshot.

JPEG vs PNG matters too. JPEG at quality 0.92 is about 6× smaller than the equivalent PNG and visually identical for slide content. If your slide is mostly photographic — gradients, real photos — JPEG. If it's text and flat fills with hard edges, push quality to 0.98 or switch to PNG above quality 0.9. Below quality 0.9, PNG is wasteful.

A live file-size estimator is cheap to write — bytes-per-pixel × pixel count × slide count — and dramatically improves the experience. Users tune the slider until the size lands where they want it.

Why this beats slidev / pptx-export / Puppeteer

html2canvas-pro + jspdf runs in the user's tab. Same code that paints the slide on screen paints it into the PDF. There is no "what if the export looks different from the live deck." It can't, because it's the same render.

What to watch out for

What you ship

A "Download PDF" button. It iterates the registry, captures each slide, assembles the PDF, calls pdf.save(). Twenty lines plus the mountAndCapture helper. No build step. No server.

The browser is already a print engine. nopoint just hands you the wiring.

Reference