How to write a print stylesheet that actually works
Wrap print rules in @media print, hide navigation and widgets, reset the article container to a single block-level column, set margins with @page, and control pagination with break-inside: avoid on figures and break-after: avoid on headings. Ship the legacy page-break-* aliases alongside the modern properties, because engines still disagree.
Print CSS is the last unloved stylesheet on most sites, and unusually cheap to get right: about forty lines takes you from "the nav bar owns sheet one" to something a reader would keep. What follows is a stylesheet you can paste and trim, plus what will break it.
Start from the preview, not from the CSS
Open your own article and press Ctrl+P before you write a single rule. That preview is the specification. Note every element that should not be there, every place the text is clipped, and every page that is blank. You will usually find the same six problems on every template, and you will fix them once.
In Chrome DevTools you can press Ctrl+Shift+P, run Show Rendering, and set Emulate CSS media type to print. That is fast for iterating on colours and hidden elements, but it does not paginate: page breaks, @page margins and orphan control only appear in the real print preview. Use both.
Layer 1: hide what paper does not need
@media print {
/* Interface that means nothing on paper */
nav, aside, footer, form, video, iframe, dialog,
.site-header, .sidebar, .comments, .newsletter,
.share-buttons, .cookie-banner, .related-posts,
[role="banner"], [role="navigation"], [role="complementary"],
[data-print="hide"] {
display: none !important;
}
/* Give the article the whole sheet */
html, body {
margin: 0;
padding: 0;
width: auto;
background: #fff;
color: #000;
font: 11pt/1.45 Georgia, "Times New Roman", serif;
}
main, article, .content, .entry-content {
display: block; /* flex and grid fragment badly across pages */
float: none;
width: auto;
max-width: none;
margin: 0;
padding: 0;
overflow: visible; /* a clipped wrapper prints one page and stops */
height: auto;
}
}Three of those lines do most of the work. display: block on the article container matters because flex and grid containers fragment unpredictably across pages, and a two-column card grid will happily slice a card in half at the page boundary. overflow: visible and height: auto matter because a wrapper with height: 100vh or overflow: hidden truncates your document to exactly one page, which is the single most common cause of "only the first page prints".
Layer 2: the page box and the breaks
@page {
margin: 18mm 16mm 20mm 16mm; /* top right bottom left */
}
@media print {
/* Never split these */
figure, table, pre, blockquote, img, li, .card {
break-inside: avoid;
page-break-inside: avoid; /* legacy alias, still honoured by some engines */
}
/* Never strand a heading at the foot of a page */
h1, h2, h3, h4 {
break-after: avoid;
page-break-after: avoid;
}
/* At least three lines of a paragraph on each side of a break */
p, li { orphans: 3; widows: 3; }
/* Repeat table headers on every page */
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
/* Optional: a wide table gets its own landscape sheet (Chromium 110+) */
@page wide { size: A4 landscape; }
.table-wide { page: wide; }
}Do not put size: A4 on the bare @page rule of a site with an international audience. A reader on US Letter then gets an A4 page scaled or clipped onto Letter paper. Setting only the margin is portable; the paper size belongs to the person holding the printer.
| What you want | Modern property | Ship alongside it | Reality check |
|---|---|---|---|
| Keep a figure whole | break-inside: avoid | page-break-inside: avoid | Solid in Chromium and Safari. Firefox is patchy on flex and grid children |
| Start a section on a fresh sheet | break-before: page | page-break-before: always | Works everywhere. Never apply it to the first heading or you get a blank opening sheet |
| Keep a heading with its text | break-after: avoid | page-break-after: avoid | Chromium honours it. Firefox often ignores it on headings |
| No stranded single lines | orphans: 3; widows: 3 | nothing | Chromium and Safari only. Firefox has never implemented it |
| Set the paper margins | @page { margin: 18mm } | nothing | Widely supported, but the user can still override it in the print dialog |
| Repeat table headers | thead { display: table-header-group } | nothing | Reliable, and one of the highest-value rules on this list |
| Force background colours | print-color-adjust: exact | -webkit-print-color-adjust: exact | A request, not a guarantee. Never let a background colour carry meaning |
Layer 3: make the links mean something
On paper a hyperlink is decoration. Printing the destination after the link text turns a printout into a document a reader can act on, which matters most for documentation, references and anything academic.
@media print {
a { color: #000; text-decoration: underline; }
/* Print the destination after external links */
a[href^="http"]::after {
content: " (" attr(href) ")";
font-size: 85%;
word-break: break-all; /* long URLs must not overflow the sheet */
}
/* ...but not for these */
a[href^="#"]::after,
a[href^="mailto:"]::after,
a[href^="tel:"]::after,
a[href^="javascript:"]::after,
a.no-print-url::after,
nav a::after {
content: "";
}
/* Expand abbreviations too */
abbr[title]::after { content: " (" attr(title) ")"; }
}Add a.no-print-url to any link whose visible text is already the URL, or you will print https://example.com (https://example.com) on every reference line. The nav a::after reset is belt and braces for themes that keep a breadcrumb visible on paper.
Layer 4: colour, images and the dark-mode trap
The nastiest modern print bug is dark mode. If your colour tokens are redefined in a prefers-color-scheme: dark block and your print block does not redefine them, a reader whose operating system is set to dark gets a solid black A4 rectangle and an empty toner cartridge. Print styles must come after, and must reassert the colours.
@media print {
/* Reassert light values regardless of the reader's colour scheme */
:root {
--bg: #fff;
--fg: #000;
--muted: #444;
--line: #999;
}
body { background: #fff !important; color: #000 !important; }
img, svg, canvas {
max-width: 100% !important;
height: auto !important;
break-inside: avoid;
}
/* Keep code blocks readable and unbroken */
pre, code {
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 9pt;
white-space: pre-wrap;
word-wrap: break-word;
border: 1px solid #ccc;
}
/* A fixed header repeats or clips on every sheet */
.site-header, .toolbar, [style*="position:fixed"] {
position: static !important;
}
}Lazy-loaded images are the other silent failure. Chromium now eagerly loads loading="lazy" images before printing, but a custom IntersectionObserver loader will still print blank boxes for anything the reader never scrolled past. If you rolled your own, force the images in before the dialog opens:
// Load every deferred image before the print dialog paints
window.addEventListener('beforeprint', () => {
document.querySelectorAll('img[data-src]').forEach((img) => {
if (!img.src) img.src = img.dataset.src;
});
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
img.loading = 'eager';
});
});
// Same event, if you need to expand collapsed sections
window.addEventListener('beforeprint', () => {
document.querySelectorAll('details').forEach((d) => (d.open = true));
});The six bugs you will actually hit
| Symptom | Cause | Fix |
|---|---|---|
| Only the first page prints | height: 100vh or overflow: hidden on a wrapper | Set both to auto and visible inside @media print |
| The whole sheet is solid black | Dark-mode tokens applied and never overridden for print | Redeclare the colour custom properties inside the print block |
| Images are missing | Lazy loading, or the image is a CSS background | Force-load on beforeprint; use <img> for content images, never background-image |
| A card grid slices a card in half | Flex and grid fragmentation | Set the container to display: block for print |
| The header appears on every sheet | position: fixed | Set position: static in @media print |
| A blank sheet at the end | A trailing break-after: page, or a tall hidden footer | Audit the last break rule; hide the footer entirely |
A testing routine that catches the rest
- Test the real preview in three enginesChrome, Firefox and Safari paginate differently. Firefox in particular ignores orphans, widows and some break-after rules, so a layout that is perfect in Chrome can strand headings in Firefox.
- Test both paper sizesSwitch the print dialog between A4 and Letter. A4 is 20mm narrower and 17mm taller than Letter, which is enough to push a wide table over the edge.
- Test in dark modeSet the operating system to dark, reload and open the preview. This catches the black-page bug that never shows up on a developer machine set to light.
- Test your longest and your widest pageThe longest article finds your break bugs; the widest data table finds your overflow bugs. Everything else sits between them.
- Save as PDF and read itPrint to PDF and actually read the file at 100 percent. Check that the text is selectable, that the URLs are expanded, and that nothing is clipped at the margin.
- Check the headers you cannot controlThe browser adds its own title, URL, date and page numbers. You cannot remove them from CSS; the reader turns them off. Design assuming they are on.
Render a page to PDF to see exactly what your print stylesheet produces.
HTML to PDF →Extract the text from your printed PDF to confirm the text layer survived intact.
PDF to Text →Now give readers a way to reach it: a print button on any site, or on WordPress the button routes. For the wider audit, run through making your website printer-friendly. To see how a page behaves with no print CSS at all, paste it into the web page printer.
Frequently asked questions
Why is my print CSS not working?
Three usual causes: the rules sit outside a @media print block, a dark-mode block later in the cascade overrides them, or you tested with the DevTools media emulator, which does not paginate. Always confirm in the real Ctrl+P preview.
What is the difference between break-inside and page-break-inside?
break-inside is the modern CSS Fragmentation property; page-break-inside is the legacy alias. Browsers map the old names onto the new ones, but engine support still differs in edge cases, so shipping both costs one line and removes the guesswork.
How do I print the URL of a link in CSS?
Use a[href^="http"]::after { content: " (" attr(href) ")"; }. Add resets with content: "" for anchors, mailto, tel and any link whose visible text is already the URL, and set word-break: break-all so long URLs do not overflow.
Can I control the paper size from CSS?
Yes, with @page { size: A4 } or size: letter, but you usually should not. A reader on the other paper size gets scaled or clipped output. Set only the margins and let the print dialog decide the sheet.
Can I remove the browser header and footer with CSS?
No. The page title, URL, date and page numbers are added by the browser, outside the document, and only the reader can turn them off in the print dialog. Design your margins on the assumption that they are switched on.