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
- Slidev rasterises markdown. You don't get arbitrary React.
- pptx-export libraries write PowerPoint XML. You're rebuilding the slide twice — once in code, once in
.pptx-shaped objects. They never look the same. - Puppeteer is great but requires a server, a Chrome binary, and a separate code path. The whole point of "the browser is the renderer" is that the browser is the renderer.
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
- Web fonts: pre-load them on the export host or accept the 300ms wait.
position: fixedslides: they break offscreen rendering. Use container-relative positioning.- Large background images: rasterise once, cache the canvas, reuse for the PDF.
- iframes and third-party embeds: html2canvas can't see across origins. Inline the content or replace with a placeholder for export.
- Print stylesheets: don't bother. The export isn't a print job.
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.
Read next
- A slideshow is a website with a PDF export — the thesis
- Live-tunable slides — adding interactive controls that disappear in the export
Reference
html2canvas-pro— the maintained fork that handles modern color spacesjsPDF— the PDF assembler