Print and PDF Export
Print stylesheets that survive real printers and PDF viewers — @page sizing, page-break control, color fidelity, and deterministic headless export
The Problem
Print is a second render target, and screen CSS actively fights it. Layouts built for scrolling viewports, animations, and interactive chrome produce broken PDFs unless every print-hostile behavior is explicitly neutralized. These failures come from real projects:
A 2-slide deck exported as a 4-page PDF — the on-screen header and toolbar were never hidden for print, added height to each page, and forced extra page breaks.
Backgrounds and gradients silently vanished from the printed output. Browsers strip backgrounds by default to save ink, and nothing warns you.
Every export ended with a trailing blank page because the last printable unit still requested a page break after itself.
A PDF opened in macOS Preview showed opaque grey boxes where
box-shadowblur should have been.
AI agents typically write @media print { .header { display: none; } } and stop there. A reliable print pass needs page sizing, break control, color fidelity, animation freezing, and layout un-doing — each with its own non-obvious pitfalls.
The Solution
Treat print as an explicit render pass with two cooperating layers:
A CSS layer: literal
@pagesize, per-unit break control,print-color-adjust: exact, frozen animations, and a full@media printblock that hides screen chrome and restores natural block flow.An export layer (for automated PDF generation): headless Chromium's
page.pdf()driven by the same size constants the CSS uses, invoked only after fonts have loaded.
Why this article uses code blocks instead of live demos
Print behavior — @page, page breaks, print heuristics — cannot render inside the live preview iframe used elsewhere on this site. Print-only rules appear as code blocks. The one screen-renderable comparison (box-shadow vs filter: drop-shadow()) has a live demo.
Page Size with @page
The size descriptor sets the physical page dimensions. Values must be literal — custom properties are not reliably resolved inside @page:
/* BAD — var() inside @page is silently ignored in Chromium */
:root {
--page-w: 1280px;
--page-h: 720px;
}
@page {
size: var(--page-w) var(--page-h);
margin: 0;
}
/* GOOD — literal values */
@page {
size: 1280px 720px;
margin: 0;
}When page dimensions depend on runtime state (a user-selectable aspect ratio, for example), keep a static per-ratio table and emit a literal rule at render time:
const PAGE_SIZES = {
"16:9": { width: 1280, height: 720 },
"4:3": { width: 1024, height: 768 },
};
function applyPageSize(ratio) {
const { width, height } = PAGE_SIZES[ratio];
let style = document.getElementById("print-page-size");
if (!style) {
style = document.createElement("style");
style.id = "print-page-size";
document.head.appendChild(style);
}
style.textContent = `@page { size: ${width}px ${height}px; margin: 0; }`;
}Two placement rules for this injected style:
@pagecannot live inside a shadow root. Web-component styles are scoped to the shadow tree, and page-level descriptors are ignored there. Inject the rule intodocument.head.If the page mutates after load (ratio switch, route change), re-apply the rule on
beforeprintso the print dialog always sees the current dimensions:
window.addEventListener("beforeprint", () => applyPageSize(currentRatio));Page-Break Control
Give every printable unit an explicit break policy. Use the modern break-* properties plus the legacy page-break-* aliases for engine coverage:
@media print {
.slide {
break-after: page;
break-inside: avoid;
page-break-after: always;
page-break-inside: avoid;
}
}Suppressing the Trailing Blank Page
break-after: page on the last unit requests a page break after the final page — many engines honor it and emit a blank page. Suppress it on the last unit:
@media print {
.slide:last-child {
break-after: auto;
page-break-after: auto;
}
}When the Last Visible Unit Is Not :last-child
:last-child fails when units can be hidden or skipped (filtered lists, draft slides, conditional sections) — the DOM-last element may not be the visually-last one. Mark the last visible unit with a data attribute from JS and target that instead:
const units = [...document.querySelectorAll(".slide")];
units.forEach((el) => el.removeAttribute("data-print-last"));
const visible = units.filter((el) => el.offsetParent !== null);
visible.at(-1)?.setAttribute("data-print-last", "");@media print {
.slide[data-print-last] {
break-after: auto;
page-break-after: auto;
}
}Run the marking code on beforeprint (and before automated export) so it reflects the current visibility state.
Color Fidelity
Browsers assume printed backgrounds waste ink and drop background colors, background images, and gradients by default. Opt out explicitly:
@media print {
:root {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}The property is inherited, so setting it on :root covers the document. Firefox supports the unprefixed print-color-adjust; Chromium and Safari need the -webkit- prefix — ship both. Without this, dark-background designs print as white pages with white text: content that exists but is invisible.
For automated export, the browser-side property is only half the story — headless Chromium additionally needs printBackground: true in the page.pdf() call (see Deterministic PDF Export).
Freezing Animations and Transitions
Printing snapshots the page mid-animation: an element halfway through a fade-in prints half-transparent; a slide-in prints half off-page. Jump every animation to its end state for print:
@media print {
*,
*::before,
*::after {
animation-duration: 0.001s !important;
animation-delay: 0s !important;
animation-iteration-count: 1 !important;
transition: none !important;
}
}Use a near-zero duration, not animation: none. Removing the animation entirely also removes its fill-mode: forwards end state — an entrance animation that fades from opacity: 0 would snap back to invisible. A 0.001s duration lets the animation complete instantly, so forwards fill states apply and elements print in their final, settled positions.
The animation-iteration-count: 1 override handles looping animations: an infinite animation with a near-zero duration does not settle — it cycles rapidly, and the print snapshot lands on a random frame. Forcing a single iteration lets it finish.
The macOS Preview Shadow Bug
Chromium's PDF backend (Skia) encodes box-shadow blur as a PDF soft mask. Adobe Acrobat and browser PDF viewers render these correctly — but macOS Preview renders the soft mask as an opaque grey box, covering the content behind it. Because Preview is the default PDF viewer on macOS, this bug reaches a large share of readers.
The workaround: swap box-shadow for filter: drop-shadow() under print. drop-shadow() is rasterized through a different code path that survives Preview:
@media print {
.card {
box-shadow: none;
filter: drop-shadow(0 2px 6px hsl(0 0% 0% / 0.25));
}
}This is a targeted workaround, not a default. drop-shadow() follows the element's alpha shape rather than its border box, ignores spread, and cannot express inset or multiple layered shadows with independent offsets. Apply it only to elements where the grey-box problem actually reproduces in Preview.
On screen the two render nearly identically for a simple card, which is what makes the swap safe:
Un-Doing Screen Layout
Screen layouts for fixed-size content (slide viewers, letterboxed previews) rely on transforms for scaling, overflow for scroll containers, and aspect-ratio for responsive sizing. All of these corrupt print output: a scale() transform prints the content scaled, a scroll container clips everything below the fold, and aspect-ratio fights the fixed page size.
Print wants natural block flow at design size. Reset the screen machinery per unit:
@media print {
html,
body {
margin: 0;
padding: 0;
}
.viewer {
display: block;
transform: none;
overflow: visible;
height: auto;
}
.slide {
width: 1280px;
height: 720px;
aspect-ratio: auto;
transform: none;
overflow: visible;
}
}The html/body margin reset is not optional: the browser's default 8px body margin sits outside the unit, displacing it beyond the zero-margin page box — enough to clip edges or push content onto an extra page.
The fixed width/height per unit must match the @page size (minus any @page margin) so each unit fills exactly one page. When the page size is dynamic (the ratio-table pattern from Page Size with @page), emit the unit dimensions from the same injected rule so both change together and cannot drift apart:
style.textContent = `
@page { size: ${width}px ${height}px; margin: 0; }
@media print { .slide { width: ${width}px; height: ${height}px; } }
`;Hiding Screen Chrome
Every print-adjacent route needs an explicit @media print pass that hides all non-content chrome — headers, nav, toolbars, sidebars, cookie notices, debug panels:
@media print {
.site-header,
.site-nav,
.viewer-toolbar,
.cookie-notice,
.debug-panel {
display: none;
}
}This is the highest-frequency real-world print failure. Chrome elements do not merely appear in the PDF — they add height above and between printable units, pushing content across page boundaries. The 2-slide deck that exported as 4 pages did so because an unhidden header consumed part of page 1, pushing slide 1's bottom half to page 2, and so on. The symptom (wrong page count) appears far from the cause (a visible header), which makes it slow to diagnose.
Audit by elimination: in the print preview, everything that is not a printable unit must be gone. A whitelist mindset — hide everything, then let content back in — is more reliable than hiding chrome elements one by one as they are discovered.
Deterministic PDF Export
For automated PDF generation, drive headless Chromium (Playwright or Puppeteer) instead of asking users to print manually. Two rules make the output deterministic:
Import page dimensions from the same constants module the CSS emission uses — a single source of truth, so the
@pagerule and thepage.pdf()call cannot drift.Wait for
document.fonts.readybefore exporting. Font fallback differs per environment, and CJK text in particular reflows when the real font arrives — exporting early produces PDFs whose line breaks depend on network timing.
import { PAGE_SIZES } from "./page-sizes.js";
const { width, height } = PAGE_SIZES["16:9"];
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.pdf({
path: "deck.pdf",
width: `${width}px`,
height: `${height}px`,
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});printBackground: true is the export-side counterpart of print-color-adjust: exact — the CSS property covers the user-driven print dialog, the API option covers headless export. Both paths need their switch flipped, or backgrounds vanish on that path.
Quick Reference
| Scenario | Technique |
|---|---|
| Exact page dimensions | @page { size: <literal>; margin: 0; } emitted from a constants table |
| Dynamic page size | Inject literal @page rule into document.head, re-apply on beforeprint |
| One unit per page | break-after: page + break-inside: avoid (+ page-break-* aliases) |
| Trailing blank page | :last-child { break-after: auto; } or data-print-last JS fallback |
| Backgrounds missing | print-color-adjust: exact + -webkit-print-color-adjust: exact on :root |
| Mid-animation frames printed | animation-duration: 0.001s, animation-delay: 0s, animation-iteration-count: 1, transition: none |
| Grey boxes in macOS Preview | Swap box-shadow for filter: drop-shadow() under print (targeted) |
| Screen layout leaking into print | Reset transform, overflow, aspect-ratio; fixed design size per unit |
| Wrong page count | Hide all non-content chrome under @media print |
| Automated export drift | page.pdf({ width, height, printBackground: true }) from shared constants, after document.fonts.ready |
Common AI Mistakes
Using
var()inside@page— Custom properties are not reliably resolved in@pagedescriptors; Chromium silently ignores the rule. Emit literal values from a constants table instead.Putting
@pageinside a shadow root — Page-level descriptors are ignored in shadow-scoped stylesheets. The rule must live in a document-level stylesheet indocument.head.Freezing animations with
animation: none— This discardsfill-mode: forwardsend states, so entrance-animated elements print at their pre-animation state (oftenopacity: 0— invisible). Use a near-zeroanimation-durationinstead.Assuming backgrounds print by default — They are stripped by ink-saving heuristics. The print dialog path needs
print-color-adjust: exact; the headless path additionally needsprintBackground: true.Forgetting the trailing
break-aftersuppression —break-after: pageon every unit including the last one produces a blank final page.Hiding chrome selectively instead of exhaustively — One leftover visible header changes the page count of the whole document. Audit the print preview until only printable units remain.
Applying the
drop-shadow()swap globally — It ignoresspread, insets, and layered shadows. Use it only where the macOS Preview grey-box bug actually reproduces.Exporting before fonts load — PDF line breaks then depend on font-arrival timing, which is nondeterministic — especially visible with CJK text. Await
document.fonts.readyfirst.
When to Use
Any User-Printable Route
Every route users might print needs at minimum: a chrome-hiding @media print block and print-color-adjust: exact. This is cheap insurance against invisible-content and wrong-page-count bugs.
Slide Decks and Fixed-Size Documents
Add the full stack: literal @page size from a constants table, per-unit break control with trailing-break suppression, and the screen-layout reset. The unit's fixed print size must match the @page size.
Automated PDF Pipelines
Drive headless Chromium with page.pdf(), sharing size constants with the CSS and waiting on document.fonts.ready. Keep the browser-side print CSS intact — headless export goes through the same @media print pass.
The Preview Shadow Workaround
Only when PDFs are consumed on macOS and the grey-box artifact reproduces in Preview. It is a compatibility patch, not a best practice to apply preemptively.