Make It Print: Tailwind v4 Print Styles for Documents People Actually Print

Tailwind print styles done properly: the print variant, page break control, @page margins, backgrounds that survive printing and table headers that repeat.

Steven Richardson
Steven Richardson
· 10 min read

Somebody in operations prints the dispatch schedule every morning. Page one is the nav bar and a cookie banner, the sidebar squeezes the table into a column four words wide, row 14 is sliced in half across the page break, every status badge is a blank white rectangle, and page four is the footer. The usual reaction is to build a separate print route or generate the PDF server-side. You rarely need either — Tailwind print styles plus about ten lines of real CSS turn the page you already have into a document.

Hide the chrome with Tailwind print styles#

Start by removing everything that exists for a screen. The print: variant compiles straight to @media print, so hiding the navigation, the sidebar, the sticky header and the action buttons is a single pass over the Blade template.

<header class="sticky top-0 z-10 print:hidden">…</header>

<aside class="w-64 shrink-0 print:hidden">…</aside>

<div class="flex gap-2 print:hidden">
    <button type="button">Export</button>
    <button type="button">Email</button>
</div>

<main class="mx-auto max-w-3xl px-6 print:max-w-none print:px-0">
    …
</main>

That compiles to exactly what you would have written by hand:

@media print {
  .print\:hidden { display: none; }
  .print\:max-w-none { max-width: none; }
}

Nothing clever, but it is the 80% of the job, and print: composes with every other utility you already use.

Flatten the layout for paper#

Paper has one column and no viewport. A layout built with a grid, a max-width and a sticky element needs flattening, and the overrides are short. The one that bites is overflow-hidden on an ancestor: in print it truncates everything past page one, and the symptom — "my content is missing" — gives you no hint where to look.

<div class="grid grid-cols-[16rem_1fr] gap-8 print:block">
    <main class="overflow-hidden rounded-xl bg-white shadow print:overflow-visible print:rounded-none print:shadow-none">

Add a belt-and-braces reset in app.css for ancestors you do not control, such as a layout component from a package:

@media print {
  html,
  body {
    height: auto;
    overflow: visible;
  }

  /* fixed elements repeat on every page in some engines, page one only in others */
  .sticky,
  .fixed {
    position: static !important;
  }
}

Control where the pages break#

This is the part that decides whether the printout is readable, and the good news is that Tailwind covers most of it. break-inside-avoid, break-after-page and break-before-avoid are core utilities — the brief I wrote for this article claimed otherwise and was wrong. Put break-inside-avoid on anything that is meaningless when sliced: a table row, a card, a figure with its caption.

<tr class="break-inside-avoid">…</tr>

<article class="break-inside-avoid rounded-lg border p-4">…</article>

<section class="break-after-page">…</section>

<h2 class="break-after-avoid">Deliveries</h2>

break-after-avoid on a heading is the one people forget. Without it you get a heading alone at the bottom of page two and its content on page three.

What Tailwind does not give you is orphans and widows, which stop a paragraph leaving one line stranded. Define them once with the @utility directive — the same mechanism covered in defining custom utilities in CSS with Tailwind v4's @utility:

@utility orphan-safe {
  orphans: 3;
  widows: 3;
}

Apply it to your prose container. Firefox ignores both properties, so treat it as an improvement in Chromium and WebKit rather than a guarantee.

Set the page margins with @page#

@page controls the physical margin and the paper size, and it has no utility equivalent in any version of Tailwind. It lives at the top level of your stylesheet, not inside @media print — it only applies to paged media anyway.

@page {
  size: A4;
  margin: 18mm 14mm 16mm;
}

/* a bit more room on page one, under the printed title block */
@page :first {
  margin-top: 12mm;
}

Use size: letter if your readers are in the US, or omit size entirely and let the print dialog decide — which is usually the honest answer when you do not know the audience. Setting a margin here also gives you room to turn the browser's own header and footer off in the dialog and print your own instead.

Keep colour meaningful on paper#

Browsers drop background colours when printing to save ink. A status badge whose entire meaning is bg-red-100 prints as a white rectangle with white text in it. You have two options and the second is better.

Forcing the colour through works:

@utility print-exact {
  -webkit-print-color-adjust: exact; /* Safari and older Chromium */
  print-color-adjust: exact;
}

It is appropriate for a chart or a colour-coded calendar where the fill is the data. It still depends on the user leaving "Background graphics" ticked in the print dialog, and it burns toner.

The better fix is markup that does not need the fill. Give the badge a border and a text label, driven by the same data-* attribute you already use for its screen styling — the pattern in styling state with data-* and aria-* variants:

<span
    data-status="overdue"
    class="rounded px-2 py-0.5 text-xs font-medium
           data-[status=overdue]:bg-red-100 data-[status=overdue]:text-red-800
           print:border print:border-current print:bg-transparent print:text-black"
>
    Overdue
</span>

On screen it is a red pill. On paper it is a bordered box that says "Overdue". Nothing is lost.

A link that reads "the full policy" is useless on paper. One rule fixes it, but only if you scope it — otherwise every nav item and every in-page anchor gets a URL stapled to it.

@media print {
  .prose a[href^="http"]:not([href*="richdynamix.com"])::after {
    content: " (" attr(href) ")";
    font-size: 0.8em;
    font-weight: 400;
    word-break: break-all; /* long URLs otherwise overflow the page box */
  }
}

word-break: break-all matters here for the same reason it matters in a table — a 90-character tracking URL will push past the page margin and get clipped. The long URL that breaks your table covers the screen-side version of that problem.

Force the light palette for print#

If you ship dark mode, someone printing at 11pm gets a black page and an angry printer. The conflict is real and hoping is not a strategy. Override the tokens inside the print media query.

@media print {
  :root,
  :root.dark,
  [data-theme="dark"] {
    --color-bg: #ffffff;
    --color-fg: #000000;
    --color-muted: #444444;
    --color-border: #cccccc;
  }
}

That works because the variant is token-driven rather than class-driven at the leaf level — the setup in CSS-first dark mode with @custom-variant. If your dark styles are written as dark:bg-slate-900 on individual elements instead, you will need print:bg-white print:text-black on those elements, which is the argument for tokens in the first place.

Repeat the table header on every page#

A ten-page table whose header only appears on page one is unreadable from page two onward. thead already defaults to display: table-header-group, so this is a restore rule rather than a trick — and you need it because "responsive table" CSS routinely sets display: block or display: flex on table parts and destroys the behaviour.

@media print {
  table { width: 100%; }
  thead { display: table-header-group; }
  tfoot { display: table-footer-group; }
  tr    { break-inside: avoid; }
  th, td { padding: 4px 6px; }
}

While you are in there, numeric columns in a printed report should be tabular-nums so the digits line up in a column — see numbers that stop jittering.

A printout sitting on a desk a week later should say what it is. Render a block that only exists in print, with the document title, the generated timestamp and whatever scoping the reader needs.

<div class="hidden print:block border-b border-black pb-2 mb-6">
    <h1 class="text-lg font-semibold">Dispatch schedule — {{ $date->format('l j F Y') }}</h1>
    <p class="text-xs">
        Generated {{ now()->format('j M Y H:i') }} · {{ $depot->name }} · {{ config('app.url') }}
    </p>
</div>

Browsers will not repeat that on every page — running headers need @page margin boxes, which Chromium does not support. If you need a page number on every sheet, let the browser's own footer do it and do not fight it.

Test your Tailwind print styles at both paper sizes#

Print to PDF at A4 and at US Letter before you ship, and open the PDF rather than trusting the preview thumbnail. Check the page count, check that no row or card straddles a break, check that the last page is not a single orphaned line, and check that nothing is clipped at the right margin.

A short pre-ship pass:

[ ] Nav, sidebar, buttons, cookie banner gone
[ ] Content full width, nothing clipped at the right edge
[ ] No table row or card split across a page break
[ ] Table header repeats on pages 2+
[ ] Badges readable without background colour
[ ] External link URLs printed, internal ones not
[ ] Dark mode on → still prints light
[ ] Page count sane at both A4 and Letter

Chrome's preview is not the renderer your reader's office printer driver uses, and margins are exactly where the two diverge. If a printout matters commercially, print one on real paper once.

Gotchas and Edge Cases#

overflow: hidden on an ancestor truncates everything past page one. This is the most common "my content is missing" print bug, and the culprit is usually a card wrapper or a layout component from a package. print:overflow-visible on the wrapper, plus the html, body reset above.

Fixed elements are inconsistent. A position: fixed toolbar repeats on every page in some engines and appears only on page one in others. Unset it for print rather than relying on either behaviour.

Lazy-loaded images may print blank. Images with loading="lazy" that have not entered the viewport can be missing from the printout in both Chromium and WebKit. If images matter, swap the attribute before the dialog opens:

window.addEventListener('beforeprint', () => {
    document.querySelectorAll('img[loading="lazy"]')
        .forEach(img => img.loading = 'eager');
});

The same applies to sections using content-visibility: auto — verify them in an actual print-to-PDF rather than assuming.

orphans and widows are Chromium/WebKit only. Firefox ignores them silently. Nothing breaks, you just do not get the improvement.

@page margin boxes are mostly unimplemented. @page { @bottom-center { content: counter(page) } } is in the spec and does nothing in Chrome. Use the browser's own header/footer for page numbers.

Wrapping Up#

Do the three high-value things first: print:hidden on the chrome, break-inside-avoid on rows and cards, and an @page margin. That is twenty minutes and it converts most pages from unusable to fine. Everything after that — badges, link URLs, the dark-mode override — is polish you add once and forget.

If your print tokens are fighting your screen tokens, the root cause is usually that the design system never had a token layer; building a design system with Tailwind v4 tokens and Blade components is the fix. And if break-inside-avoid is not applying at all, check you are on v4 proper — the official v3 to v4 upgrade tool will tell you where you actually are.

FAQ#

How do I add print styles with Tailwind CSS?

Use the built-in print: variant on any utility — print:hidden, print:block, print:text-black. Tailwind compiles those into an @media print block for you, so there is no plugin or config change needed. For the handful of print properties with no utility equivalent, such as @page margins and orphans, write plain CSS in your app.css alongside the @import "tailwindcss" line.

How do I stop a table row breaking across pages when printing?

Add break-inside-avoid to the <tr>. It is a core Tailwind utility that compiles to break-inside: avoid, and it tells the browser to keep the row on a single page rather than slicing it at the page boundary. Apply the same class to cards, figures and any block whose meaning depends on staying whole, and pair it with break-after-avoid on headings so a heading is never orphaned from its content.

How do I set page margins for printing in CSS?

Use the @page at-rule at the top level of your stylesheet: @page { size: A4; margin: 18mm 14mm; }. There is no Tailwind utility for this because it controls the physical page box rather than an element. You can target the first sheet separately with @page :first, and size accepts named sizes such as A4 and letter as well as explicit dimensions.

Why are background colours missing when I print a web page?

Browsers strip background colours and images by default when printing to save ink, and most print dialogs hide the "Background graphics" option behind a "More settings" toggle. You can force a specific element to keep its fill with print-color-adjust: exact (plus the -webkit- prefix for Safari), but the more reliable fix is to not depend on fill — give the element a border and a text label so it reads correctly in black and white.

How do I repeat a table header on every printed page?

Set thead { display: table-header-group; } inside your @media print block. That is the default for thead, so the rule exists to undo responsive CSS that sets display: block or display: flex on table parts and silently breaks header repetition. Add tfoot { display: table-footer-group; } if you have a totals row you want on every sheet.

How do I hide navigation and buttons when printing?

Put print:hidden on each one — the header, the sidebar, the action button group, the cookie banner. It compiles to display: none inside @media print, and because it is a normal variant you can apply it to a wrapper rather than every child. Checking the printout for leftover chrome is worth doing with the dialog open, since sticky and fixed elements sometimes survive in ways the preview does not make obvious.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.