CSS is Awesome

Recipes

Print to PDF

Pixel-faithful PDF export from any page using only a @media print stylesheet — no library, no server.

layoutsimplecia >=1.0.0

Use this when

You want users to save a page as a faithful PDF — résumés, invoices, receipts, reports, tickets, order confirmations — and you'd rather not pay for a PDF service (DocRaptor, Prince, PDFShift) or pull in a rendering library. A browser already has a layout engine and a PDF writer wired together; you just describe the page on paper with @media print, and the user saves it from the print dialog. The page itself is the PDF source — there's no second template to keep in sync. If your "PDF" is really a fixed artifact unrelated to the page (a generated certificate, a pre-printed form to fill), this isn't it — use a real PDF library. If you need PDFs generated with no human present (emailing invoices, batch export), the same stylesheet still drives it — see the automation note under Interactivity.

Structure (raw HTML)

There's almost nothing to add — the page is the structure. The only print-specific markup is marking the on-screen chrome that shouldn't appear on paper. Put data-cia-recipe on the document root so tooling can find it.

<body data-cia-recipe="print-to-pdf">
  <nav class="site-nav">…</nav>           <!-- hidden on paper -->

  <main class="doc" data-slot="document">
    <h1>Quarterly Report</h1>
    <p>…</p>
    <a href="https://example.com/details">Full details</a>
  </main>

  <footer class="site-footer">…</footer>  <!-- hidden on paper -->

  <!-- Optional signpost. Ctrl+P works with or without it. -->
  <button type="button" class="no-print" onclick="window.print()">Save as PDF</button>
</body>

Notes on the markup:

  • No button is required. @media print applies to every print path — Ctrl/Cmd+P, File → Print, the dialog's built-in "Save as PDF" destination. The button is a discoverability signpost only; it says "this page was designed to become a PDF."
  • Mark every piece of site chrome — nav, footer, the button itself — so the print layer can hide it. A single shared class (no-print) plus your structural elements is enough.
  • The <a> keeps its real href. On paper a clickable link is dead, so we print the URL after it (see Pitfalls).

Styling (cia mixins)

cia owns the @media print layer through four mixins. print-base ships the page-level defaults on; the rest are per-element.

This recipe is the one case that spans both halves of cia's two-import model, so the code below is split accordingly. Both files import the same zero-emit authoring barrel, css-is-awesome/api — what differs is where the rules land.

1. In your GLOBAL stylesheetprint-base emits its own :root block plus @page, so it must sit at the top level of a global/root stylesheet. Never put it inside a component module: a top-level :root is a hard build error under Next.js CSS Modules pure mode.

// app/globals.scss (or your single root stylesheet) — included ONCE.
@use 'css-is-awesome/api' as cia;

// Page-level defaults — at the ROOT, never inside a selector (it emits @page).
// Sets the @page box and freezes animations so nothing prints invisible.
// Defaults are on; toggle via args.
@include cia.print-base;                            // size: letter, margin: 0.5in, freeze on
// @include cia.print-base($size: A4, $margin: 0.75in);   // override the paper
// @include cia.print-base($freeze-animations: false);    // opt out of the freeze

// Make every light-dark() token resolve to its paper-friendly light value,
// so a dark theme doesn't print as white-on-white. (See Pitfalls.)
:root {
  @include cia.print { color-scheme: light; }
}

// Hide site chrome on paper — the "hide the nav" case. Site chrome is global,
// so these usually live here too; move them into the component that owns the
// element if you'd rather keep the rule next to its markup.
.site-nav,
.site-footer,
.no-print {
  @include cia.print-hidden;
}

2. In each COMPONENT stylesheet — everything else is per-element and emits nothing until you call a mixin, so it is safe in a .module.scss.

// Doc.module.scss — component stylesheet, so import the zero-emit barrel.
@use 'css-is-awesome/api' as cia;

// Co-locate per-element print overrides RIGHT NEXT TO the screen rule
// they change, so the reason is visible where you read the original.
.doc {
  background: cia.color(surface-default);
  color: cia.color(text-primary);

  @include cia.print {
    padding-block: 0;                 // strip screen chrome that wastes the sheet
  }
}

Show the minimum to make it work. The color-scheme: light flip is the cia-native fix for light-on-dark themes: instead of recoloring element by element, you tell the print sheet to use each token's light side once. Class names are consumer-chosen (my-doc, .doc) — never cia-*.

The variable filter system

print-base emits three custom properties that are the control plane for the whole print layer — flip them and the output changes without rewriting a single rule:

Variable Value What it does
--is-print 0 on screen, 1 on paper A readable print-state switch. Use it in calc(), opacity, or @container style(--is-print: 1) to drive custom print-only effects.
--print-hide none The display applied to print-hidden elements on paper.
--print-show revert The display applied to print-only elements on paper.

Because the value is a variable, a per-element exception needs no new rule — just re-aim the variable on that element:

.legal-footer { @include cia.print-hidden; --print-hide: revert; } // keep this one ON paper
.receipt-grid { @include cia.print-only;   --print-show: grid; }   // print-only block as a grid

.cover { @include cia.print { opacity: var(--is-print); } }        // fades in only on paper

That is the whole reason this recipe needs no JavaScript and no headless-browser service: the media query plus a few variables are the engine.

Why these mixins use !important

You will see !important in the compiled output. cia avoids it everywhere else; the print layer is a deliberate exception.

@media contributes no specificity. print-hidden is included inside your selector, so its rule carries exactly your selector's specificity — and a later declaration at equal specificity wins, in print too:

.site-nav {
  @include cia.print-hidden;   // @media print { display: none }
  display: flex;               // equal specificity, later — would win without !important
}

The competing rule is usually not even yours: a utility class, or a component library cia cannot see. Without !important, "hidden on paper" silently isn't — and it only shows up in a print preview.

@layer does not solve this. Layered CSS always loses to unlayered CSS, so a layered print rule would lose to any consumer stylesheet that isn't layered — which is most of them. (!important also inverts layer order, so combining them misleads.) cia ships unlayered by design; see .agent/decisions/decided/04-at-layer-decision.md.

The scope is small and the escape hatch is open: 8 declarations, all inside @media print, and every value stays variable-driven. Override --print-hide / --print-show to change what happens; you never have to fight the rule to win.

Interactivity

Zero JS. The browser runs no script and needs no server — it reads your @media print rules and renders. The user presses Ctrl/Cmd+P (or File → Print) and picks the built-in "Save as PDF" destination. That is the entire mechanism.

The only optional code is a discoverability signpost — a one-line button so users notice the page is built to be saved:

<button type="button" class="no-print" onclick="window.print()">Save as PDF</button>

It's sugar over window.print() and nothing depends on it — Ctrl+P does the same job. Hide it on paper with @include cia.print-hidden. The framework examples below show this one button in each stack.

A11y checklist

Framework examples

All four are the same optional signpost button — the only framework-specific code in this recipe, and every one is a thin wrapper over window.print(). Hide it on paper with @include cia.print-hidden (or the no-print class).

React

export function PrintButton({
  children = "Save as PDF",
  className,
}: { children?: React.ReactNode; className?: string }) {
  return (
    <button type="button" className={className} onClick={() => window.print()}>
      {children}
    </button>
  );
}

Vue

<template>
  <button type="button" @click="() => window.print()">
    <slot>Save as PDF</slot>
  </button>
</template>

Svelte

<button type="button" on:click={() => window.print()}>
  <slot>Save as PDF</slot>
</button>

Vanilla (Web Component)

class PrintButton extends HTMLElement {
  connectedCallback() {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = this.textContent.trim() || "Save as PDF";
    this.textContent = "";
    btn.addEventListener("click", () => window.print());
    this.appendChild(btn);
  }
}
customElements.define("print-button", PrintButton);

// Usage: <print-button>Save as PDF</print-button>

Variants

International paper (A4) / landscape

Pass the size through print-base (in the global stylesheet, at the root), and set orientation on @page:

// app/globals.scss — top level, not inside a selector, not in a component module
@include cia.print-base($size: A4, $margin: 0.75in);

@include cia.print {
  @page { size: A4 landscape; }
}

Force a page break before a section

Start a new sheet at a major boundary (a new invoice, a new chapter):

.section-start {
  @include cia.print { break-before: page; }
}

Print-only content (URL footer, "printed on" stamp)

Content that should appear only on paper, hidden on screen:

.print-footer { @include cia.print-only; }

Pitfalls

These are the bugs that will bite — each one cost real debugging time. This list is the value.

  • Animations snapshot invisible. Entrance fades and scroll reveals often start at opacity: 0; a PDF captured mid-animation prints blank. print-base collapses animations to zero duration and pins them to their final frame, so the fade lands visible. It does not force opacity: 1 / transform: none — that would also flatten deliberate translucency and rotation. Elements that weren't animating keep their own styling. Keep the freeze on unless you have a specific reason not to.
  • A blank trailing page. A few invisible pixels of trailing margin/padding/border on the last element spill an empty final sheet. Zero them: @include cia.print { .doc > :last-child { margin-block-end: 0; border-block-end: none; } }. Also watch a full-height scroll/perspective wrapper (height: 100vh, overflow, perspective) — in print set overflow: visible and let content flow, or it clips paged output.
  • Light-on-dark text becomes white-on-white. Anything styled light text on a dark surface vanishes on a white sheet. The cia fix is the color-scheme: light flip in print-base's block above — it lands every light-dark() token on its light value. For a one-off, override the single element with @include cia.print { color: cia.color(text-secondary); }.
  • Background colors are off by default. Browsers strip background colors and images when printing to save ink. If your design depends on them, tell users to tick "Background graphics" (Chrome) / "Print backgrounds" (Firefox/Safari) in the print dialog — there's no CSS that forces it on.
  • Links lose their destination. A clickable link is dead on paper — print the URL after it:
    @include cia.print {
      a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.85em; word-break: break-all; }
    }
    
  • Page breaks split things awkwardly. Keep headings with their content and don't split atomic blocks: @include cia.print { h2, h3 { break-after: avoid; } li, .card { break-inside: avoid; } .doc { orphans: 3; widows: 3; } }.
  • px gets fuzzy in print. Use pt / in for print typography and spacing — px is a screen unit and scales unpredictably across print zoom.

Related recipes

  • print-spec — the paginated-spec companion (cover + table index, one page per section, printed sheet numbers). It builds on this recipe and leans on print-base's opt-in flags — $legible (readable dark themes on paper), $link-urls / $link-origin (full followable URLs), $page-numbers (sheet numbers) — which automate the manual URL-and-page-break handling shown above.
  • dialog — a print-only summary often lives inside a confirmation dialog before export
Theme