Guide
Print CSS for Markdown PDFs: The Complete Guide to Page Layout
Published July 30, 2026
Converting Markdown to PDF is really two problems wearing one coat. The first — turning Markdown into HTML — is solved; every parser does it competently. The second is turning a single, infinitely tall HTML document into a stack of fixed-size pages. That is where nearly every frustration with Markdown-to-PDF conversion actually lives.
This guide covers the CSS that controls pagination: the @page rule, break control, margins, orientation, and columns. It assumes you are converting through a browser engine — Chrome’s print pipeline, Puppeteer, Playwright, WeasyPrint, or a Paged.js-based tool like this site’s editor. Where behaviour differs between engines, that is called out, because the differences are large and rarely documented.
Why print layout is a different problem
Screen CSS assumes one continuous viewport that scrolls. You can position something 4,000 pixels down and the browser simply renders it there. Print CSS has no such luxury. The renderer has to decide, for every element, whether it fits in the remaining space on the current page — and if not, whether to move it wholesale or split it across the boundary.
That single decision cascades. A table that splits badly orphans its header. A heading that lands two lines from the bottom is separated from the section it introduces. A code block that breaks mid-function is unreadable. None of these are bugs in your Markdown; they are the pagination engine making a reasonable local decision without knowing your intent.
CSS Paged Media — the W3C module that defines @page — is how you communicate that intent.
The @page rule
@page is a top-level at-rule that describes the page box itself, not the content inside it. It is where you set physical dimensions and margins:
@page {
size: A4 portrait;
margin: 25mm 20mm;
}
size accepts either a named page size (A4, A5, letter, legal, ledger) or explicit dimensions (size: 210mm 297mm). Named sizes are safer — they avoid rounding drift and communicate intent to the print driver.
Use physical units here, never pixels. A px in print is defined as 1/96th of an inch, so it technically works, but mm and in say what you mean and survive being read by someone else six months later.
Targeting specific pages
Three pseudo-classes let you vary the page box by position:
@page :first {
margin-top: 60mm; /* room for a title block */
}
@page :left {
margin-right: 30mm; /* wider inner margin for binding */
}
@page :right {
margin-left: 30mm;
}
:left and :right refer to spreads in a bound document, and only matter if you are printing double-sided. For a PDF read on screen, they are noise — skip them unless the document is genuinely going to a printer.
Support is uneven: Chrome’s print engine honours :first but has historically ignored :left/:right margins. Paged.js polyfills all three faithfully, which is one of the stronger arguments for a Paged.js-based pipeline if spreads matter to you.
Named pages
The page property lets you assign specific content to a differently-configured page box. This is the mechanism behind mixed orientation, covered below:
@page wide {
size: A4 landscape;
}
.wide-table {
page: wide;
}
Named pages are well supported in Paged.js and WeasyPrint, and partially supported in recent Chrome. If you need them and you are driving headless Chrome, test before committing.
Page breaks that actually hold
This is the single largest source of confusion, because there are two generations of properties and the older one is what most tutorials still show.
The legacy properties are page-break-before, page-break-after, and page-break-inside. The modern replacements are break-before, break-after, and break-inside. The modern ones are more capable — they handle columns and regions as well as pages — and are what the spec now defines. The legacy ones are kept as aliases.
In practice, write both. The cost is two lines and it buys compatibility across every engine you are likely to meet:
h2 {
page-break-before: always;
break-before: page;
}
The four rules worth setting on every document
Most pagination complaints disappear with these:
/* 1. Never split a heading from what follows it. */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
page-break-inside: avoid;
break-inside: avoid;
}
/* 2. Keep code blocks, tables and figures intact where possible. */
pre, table, figure, blockquote {
page-break-inside: avoid;
break-inside: avoid;
}
/* 3. Repeat table headers on every page a table spans. */
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
/* 4. No single dangling line at a page boundary. */
p, li {
orphans: 3;
widows: 3;
}
Rule 3 deserves emphasis because it is the one people miss. display: table-header-group on <thead> tells the renderer that this row is structural, and it should be reprinted at the top of each page fragment. Without it, a table spanning three pages has a header on page one and two pages of unlabelled numbers. Markdown tables produce a real <thead>, so this works on any GFM table with no extra markup.
orphans and widows
These two properties are old, well supported, and almost never used. orphans sets the minimum number of lines a paragraph must leave at the bottom of a page; widows sets the minimum carried to the top of the next. orphans: 3; widows: 3 means the renderer will push the whole paragraph rather than leave one or two stranded lines.
A caveat: aggressive orphan/widow settings on a dense document produce visible whitespace at the bottom of pages, because the engine’s only lever is to move content forward. Three is a good default. Above four you will start noticing the gaps.
avoid is a request, not a command
break-inside: avoid is advisory. If an element genuinely cannot fit on one page — a code block eighty lines long on an A4 page — the engine must break it, and it will. The property means “prefer not to”, not “must not”.
This matters for how you debug. If a long table keeps splitting despite break-inside: avoid, the fix is not a stronger CSS incantation; it is either shorter content, a smaller font for that element, or an explicit break you place yourself.
Forcing a break from Markdown
Markdown has no page-break syntax, which is by design — it is a text format, not a layout format. Every converter therefore invents its own escape hatch, and almost all of them accept raw HTML, because Markdown permits inline HTML by spec.
The portable approach is a div with a class:
<div class="page-break"></div>
.page-break {
break-after: page;
page-break-after: always;
}
Some tools recognise a bare <div style="page-break-after: always;"></div>, and a few look for a horizontal rule (---) with special styling. The class-based version is the one that survives switching tools.
One subtlety: an empty div with a forced break after it can produce a blank page if it lands at the top of a fresh page — the break fires on an element that is already at a boundary. If you see stray empty pages, that is usually why. Guard it:
.page-break {
break-after: page;
page-break-after: always;
height: 0;
}
.page-break:first-child {
break-after: auto;
page-break-after: auto;
}
Margins and the printable area
Margins in print do two jobs: they create visual breathing room, and they keep content out of the physical region a printer cannot reach.
Almost every consumer inkjet and laser printer has an unprintable border of roughly 3–5mm on each edge, and often more at the trailing edge where the paper grippers sit. A margin below about 10mm risks having content silently clipped on paper, even though the PDF looks fine on screen.
Sensible starting points:
| Document type | Margins | Reasoning |
|---|---|---|
| Business letter, report | 25mm all round | Conventional, generous, safe |
| Resume | 15–18mm | Buys vertical space to stay on one page |
| Technical documentation | 20mm sides, 18mm top/bottom | Wide code blocks need horizontal room |
| Book interior (bound) | 20mm outer, 28mm inner | Extra inner margin for the gutter |
| Dense reference table | 12–15mm | Maximise usable width; screen-only |
Asymmetric margins for binding
If the document will be bound, the inner margin needs to be wider than the outer, because the binding swallows several millimetres. With :left and :right:
@page :left {
margin: 20mm 20mm 20mm 28mm;
}
@page :right {
margin: 20mm 28mm 20mm 20mm;
}
Remember this only makes sense for double-sided printing. Applied to a screen-read PDF it just makes alternating pages look misaligned.
Bleed
If artwork must run to the very edge of the trimmed page, it has to extend past the trim line so that cutting tolerance does not leave a white sliver. That extension is the bleed:
@page {
size: A4;
bleed: 3mm;
marks: crop cross;
}
bleed and marks are part of the spec but support is thin — Paged.js and WeasyPrint handle them; Chrome’s print pipeline largely does not. Bleed is a commercial-print concern. If your PDF is being emailed rather than sent to a press, ignore it entirely.
Mixing portrait and landscape
A common real need: a mostly-portrait report with two wide data tables that only fit landscape. Named pages are the mechanism.
@page {
size: A4 portrait;
margin: 20mm;
}
@page landscape-page {
size: A4 landscape;
margin: 15mm 20mm;
}
.landscape {
page: landscape-page;
break-before: page;
break-after: page;
}
Then in your Markdown, wrap the wide content:
<div class="landscape">
| Quarter | Region | Revenue | Margin | Headcount | Notes |
| --- | --- | --- | --- | --- | --- |
| Q1 | EMEA | 1,240,000 | 31% | 84 | Baseline |
</div>
The blank lines inside the div are not optional. Markdown parsers stop processing Markdown syntax inside a raw HTML block unless a blank line reopens Markdown context. Without them, your table renders as literal pipe characters — a genuinely common and very confusing failure.
Both break-before and break-after are needed on .landscape. Without them the renderer tries to start the landscape page mid-flow and the results depend on engine internals.
The rotation alternative
If named pages are not supported by your engine, the fallback is to keep the page portrait and rotate the content:
.rotated {
transform: rotate(90deg);
transform-origin: center;
width: 257mm; /* A4 height minus margins */
height: 170mm; /* A4 width minus margins */
}
This works nearly everywhere, but the text ends up sideways in the PDF — the reader has to rotate their view, and selecting text behaves oddly. Use it only as a last resort.
Multi-column layouts
CSS multi-column is the right tool for print columns, and it composes properly with pagination:
.two-column {
column-count: 2;
column-gap: 10mm;
column-rule: 0.5pt solid #ddd;
}
The renderer fills column one to the bottom of the page, then column two, then moves to the next page. That is what you want for newsletter or academic-paper layouts.
Balance is worth controlling. By default the last page of a multi-column block balances its columns to equal height, which can look odd on a short final section:
.two-column {
column-fill: auto; /* fill sequentially rather than balancing */
}
Elements that must span the full width — a title, a wide figure — can break out:
.two-column h2,
.two-column figure.wide {
column-span: all;
}
A practical warning: two columns on A4 with 20mm margins gives each column about 80mm of width. Fenced code blocks do not fit in 80mm. If your document has code, either keep it single-column or let it span:
.two-column pre {
column-span: all;
}
Why Flexbox and Grid behave differently in print
This is the part that catches experienced front-end developers, because the mental model that works on screen quietly stops applying.
Flex and grid containers are not fragmentable in most engines. The specification does define fragmentation behaviour for both, but implementation is incomplete essentially everywhere. In practice, if a flex or grid container is taller than the remaining page space, engines either push the entire container to the next page — leaving a large gap — or clip it. Chrome’s print pipeline is notably poor here.
The rules that follow from this:
- Do not use flex or grid for page-level document flow. Normal block flow fragments correctly because that is what it was designed for. Let paragraphs, headings and tables be blocks.
- Do use flex or grid for small, self-contained units that will always fit on one page: a resume’s header with name on the left and contact details on the right, a two-up figure pair, a signature block.
- Always pair them with
break-inside: avoid, so the engine treats the unit as atomic rather than discovering it cannot split it halfway through.
/* Fine: a small, atomic unit. */
.resume-header {
display: flex;
justify-content: space-between;
align-items: baseline;
break-inside: avoid;
page-break-inside: avoid;
}
/* Not fine: this will fragment badly or not at all. */
.document-body {
display: grid;
grid-template-columns: 1fr 1fr;
}
For genuine multi-column document flow, use column-count as shown above. It was designed for fragmentation; grid was not.
Other screen-isms that fail in print
position: fixedrepeats on every page in some engines and appears once in others. For genuine repeating headers, use@pagemargin boxes rather than fixed positioning.position: stickyhas no meaning in print and is ignored.- Viewport units (
vh,vw) resolve against the page box in some engines and against a nominal viewport in others. Avoid them; usemmor percentages. overflow: hiddencan clip content that would otherwise have flowed to the next page. If content is vanishing, check for it.- CSS transitions and animations do not run. Anything whose final state depends on an animation will render in its initial state.
The print media query
Everything above can live inside @media print, and should if the same stylesheet also serves the screen:
@media print {
@page {
size: A4;
margin: 20mm;
}
nav, aside, .no-print, footer {
display: none;
}
body {
font-size: 11pt;
line-height: 1.5;
color: #000;
background: #fff;
}
/* Reveal link targets, since a reader cannot click paper. */
a[href^="http"]::after {
content: " (" attr(href) ")";
font-size: 9pt;
color: #555;
word-break: break-all;
}
}
Two notes on that link expansion. It is genuinely useful for documents that will be printed and read away from a screen — otherwise every link is invisible. But it is actively harmful in a PDF that will be read on screen, where links are clickable and the appended URLs are clutter. Decide which artefact you are producing.
Also: attr() in content is the only widely supported use of attr(). Do not expect attr() to work for arbitrary properties.
Point sizes for print
Switching to pt for print is conventional and worth doing. Print typography has different legibility constraints from screen: 16px is a comfortable screen body size, but the print equivalent is around 11pt, and 16pt on paper looks like large-print edition.
| Element | Screen | |
|---|---|---|
| Body | 16px | 10.5–11.5pt |
h1 | 32px | 20–24pt |
h2 | 26px | 15–17pt |
h3 | 20px | 12.5–13.5pt |
| Code | 14px | 8.5–9.5pt |
| Captions, footnotes | 13px | 8–9pt |
Code needs to be proportionally smaller than body text in print, because monospace fonts have wider average character advance and a fenced block set at body size will overflow the measure.
Colour, backgrounds and ink
Browsers strip background colours when printing by default — a sensible ink-saving decision that ruins syntax-highlighted code blocks and table zebra striping. Override it:
@media print {
pre, code, th, .callout {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
Both spellings are needed: the unprefixed print-color-adjust is the standard, the -webkit- prefixed version is what shipping Chrome and Safari have honoured for years.
For documents genuinely destined for a monochrome laser printer, do the opposite and design for it — replace background fills with borders, and make sure any information conveyed by colour is also conveyed by weight, shape or label. A red/green status column is useless in greyscale.
Debugging pagination
A practical sequence when a document paginates wrongly:
- Print to PDF from the browser first. Chrome’s print preview is the fastest feedback loop available. If it looks wrong there, it will look wrong everywhere.
- Add a temporary outline to see box boundaries:
* { outline: 0.5px solid rgba(255,0,0,.3); }. Overflow that was invisible becomes obvious. - Check for a stray fixed height. A
heighton a container that should grow is the most common cause of clipped rather than flowed content. - Look for flex or grid in the ancestor chain of the misbehaving element. See above.
- Reduce until it works, then add back. Comment out half your print CSS. If the problem vanishes, bisect.
- Verify in the actual target engine. Chrome, WeasyPrint and Paged.js disagree enough that “works in Chrome” is not a guarantee.
A complete starting stylesheet
This is a reasonable, conservative base for a text-heavy Markdown document:
@page {
size: A4 portrait;
margin: 22mm 20mm;
}
@page :first {
margin-top: 45mm;
}
@media print {
body {
font-family: "Source Serif 4", Georgia, serif;
font-size: 11pt;
line-height: 1.55;
color: #111;
background: #fff;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Inter", system-ui, sans-serif;
break-after: avoid;
page-break-after: avoid;
break-inside: avoid;
page-break-inside: avoid;
}
h1 { font-size: 22pt; margin: 0 0 8pt; }
h2 { font-size: 16pt; margin: 18pt 0 6pt; }
h3 { font-size: 13pt; margin: 14pt 0 4pt; }
p, li {
orphans: 3;
widows: 3;
}
pre, table, figure, blockquote {
break-inside: avoid;
page-break-inside: avoid;
}
pre {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 9pt;
line-height: 1.4;
padding: 8pt 10pt;
border: 0.5pt solid #ddd;
border-radius: 3pt;
white-space: pre-wrap;
word-wrap: break-word;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 9.5pt;
}
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
th, td {
border: 0.5pt solid #ccc;
padding: 4pt 6pt;
text-align: left;
}
th {
background: #f4f4f4;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
img { max-width: 100%; }
.page-break {
break-after: page;
page-break-after: always;
height: 0;
}
.no-print { display: none; }
}
Note white-space: pre-wrap on pre. Screen code blocks scroll horizontally; paper cannot. Without wrapping, long lines are simply cut off at the margin. Wrapping is uglier than scrolling but it is the only option that does not lose information.
Where the browser approach ends
Browser-based pagination has real limits worth knowing before you invest in it. There is no automatic index generation, no cross-reference resolution (“see page 47”), and no proper float placement for figures with text wrapping around them. Footnote support depends entirely on the engine and Paged.js’s implementation, which is good but not LaTeX.
If you need those — a 400-page technical book with an index and numbered cross-references — LaTeX or InDesign is the honest answer, and no amount of print CSS will close that gap. For everything shorter than a book, print CSS in a browser is now genuinely competent, and it has the enormous advantage that you can iterate in devtools instead of recompiling.
Try it yourself
The editor on this site implements the CSS Paged Media model through Paged.js, so the paginated preview you see is the PDF you download — same page size, margins, breaks and orientation. Themes ship with sensible break rules already applied, and you can layer your own CSS on top.
For the layers above pagination, see the companion guides on headers, footers, cover pages and contents and typography and styling.
About this guide
Written and maintained by the developer of MarkdownToFile — the browser-based converter this site runs on. Techniques described here are tested against the engines named in the text (Chromium's print pipeline, Paged.js, WeasyPrint), and engine limitations are stated rather than glossed over. Corrections are welcome via the contact page; more about the project on the about page.
Try it yourself — free, no signup
Convert your Markdown to a polished PDF right in your browser.
Open the editor