Mert Özen Further With Every Line
Frontend CSS Properties: A Hands-On Guide — #13

Table and Print Styles: border-collapse, table-layout, Sticky Headers, Numeric Alignment, @media print and @page

Mert Özen Nov 15, 2026 15 min 17 views
Table and Print Styles: border-collapse, table-layout, Sticky Headers, Numeric Alignment, @media print and @page

For a data table, border-collapse: collapse, table-layout: fixed and text-align: end with tabular-nums for the money column are enough; draw the sticky header's line with box-shadow, and use @media print, @page and print-color-adjust for printing. The post measures Bookstore's My orders table and invoice in Chromium and WebKit, reads Chromium's page.pdf() output with pdf.js, and closes the series.

Version note: This post follows the CSS 2.2 (tables), CSS Fonts Level 4 (font-variant-numeric), CSS Fragmentation Level 3, CSS Paged Media Level 3 and CSS Color Adjustment Level 1 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), the PDF output was produced with Chromium's page.pdf() and read with pdf.js 6.5, and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.

Guess First

For Bookstore, the online bookstore used as the shared example across this site's series, we build the last two screens of the series: a "My orders" table with 40 orders and a printable invoice. First, the two boxes in the lab. In both the table header is sticky with a 3 pixel red line under it; the only difference is border-collapse: collapse in the second:

/* 4) a sticky header inside a scrolling box */
.box { block-size: 150px; overflow: auto; }
.box table { inline-size: 300px; border-spacing: 0; }
.box th { position: sticky; inset-block-start: 0; background: #ffffff; border: 0; border-block-end: 3px solid #b91c1c; }
.box td { block-size: 40px; border: 0; }
#sticky-collapse table { border-collapse: collapse; }

When both boxes are scrolled down by 120 pixels, does the header stay at the top, and does the red line come along?

border-collapse and border-spacing

Table borders are drawn with one of two models. With the default, separate, every cell has its own border and border-spacing leaves space between the cells. With collapse, neighboring cells share one border:

td, th { padding: 4px 8px; border: 1px solid #9ca3af; }

/* 1) separate (the default) vs collapse */
#separate { border-spacing: 4px; }
#collapse { border-collapse: collapse; }
node scripts/13-table.mjs
--- chromium
separate  gap between cells 4px, table 147.765625px
collapse  gap between cells 0px, table 134.765625px
--- webkit
separate  gap between cells 4px, table 147.765625px
collapse  gap between cells 0px, table 134.765625px

The separate table has 4 pixels between its cells and is 147.77 pixels wide. With collapse the gap is 0 and the table is 13 pixels narrower: three gaps of 4 pixels, at both edges and in the middle, and one shared 1 pixel border are gone. The measurement is the same in both engines. Data tables usually want collapse and horizontal lines only; if you want rounded table corners, border-radius does not work with collapse, so use separate with border-spacing: 0. The HTML side of tables, caption, thead, th scope and colgroup, is in post 10 of the site's HTML series (HTML5 and Semantic HTML); here we only look at how they look.

table-layout: fixed and Column Widths

With the default table-layout: auto the browser computes the column widths from the content of every cell. With fixed the widths come from the table's width, the col elements and the first row; the content of later rows does not count:

/* 2) table-layout: the content decides, or the first row does */
.layout { inline-size: 480px; border-collapse: collapse; }
.layout td { overflow-wrap: break-word; }
.layout col:first-child { inline-size: 120px; }
#fixed { table-layout: fixed; }
<table class="layout" id="auto">
    <colgroup><col><col></colgroup>
    <tr><td>Gift code</td><td>SPRINGREADERS2026FORTHEWHOLECLASSANDTHEIRTEACHERS</td></tr>
</table>
--- chromium
table-layout: auto  table 564px, columns 53 + 510, row height 57px
table-layout: fixed table 480px, columns 120 + 359, row height 57px
--- webkit
table-layout: auto  table 564px, columns 53 + 510, row height 57px
table-layout: fixed table 480px, columns 120 + 359, row height 57px

Both tables are defined 480 pixels wide and ask for a 120 pixel first column. The auto table grew to 564 pixels because of the 49 character gift code without break points, went past its width, and squeezed the first column to 53 pixels, so "Gift code" wrapped onto two lines. The fixed table stayed at 480 pixels with a 120 pixel first column; the long code broke inside its own column with overflow-wrap: break-word. Unlike anywhere, break-word does not reduce the content's minimum width, which is why it could not stop the auto table; the difference is in this series' line breaking post. fixed has one more benefit: the browser can lay out the whole table as soon as it sees the first row, so the columns of a 40 row table do not move while the page loads.

The Money Column: text-align: end and tabular-nums

Amounts are aligned to the right so the digits line up. But right alignment alone is not enough: in most fonts the digit "1" is narrower than "8", which are proportional figures. font-variant-numeric: tabular-nums asks the font to draw every digit with the same width:

/* 3) digits: proportional by default, tabular on request */
.amount { font-family: system-ui, sans-serif; }
.amount.serif { font-family: Georgia, serif; }
.tabular { font-variant-numeric: tabular-nums; }
--- chromium
system-ui:              ₺1,111.10 62.23px, ₺8,888.80 75.75px
system-ui tabular-nums: ₺1,111.10 77.23px, ₺8,888.80 77.23px
Georgia tabular-nums:   ₺1,111.10 61.72px, ₺8,888.80 75.05px
--- webkit
system-ui:              ₺1,111.10 62.23px, ₺8,888.80 75.75px
system-ui tabular-nums: ₺1,111.10 77.23px, ₺8,888.80 77.23px
Georgia tabular-nums:   ₺1,111.10 61.72px, ₺8,888.80 75.04px

In this machine's system-ui, "₺1,111.10" is 62.23 and "₺8,888.80" 75.75 pixels; with tabular-nums both are 77.23 pixels. In Georgia, tabular-nums changes nothing: the property only turns on a feature inside the font, OpenType tnum, and has no effect when the font lacks it. On Windows system-ui resolves to another font, Segoe UI, and the result may differ; check the font you choose for the money column. The typography side of this decision is in post 03 of the UI/UX Design Principles series. The measurement on Bookstore's order table:

node scripts/13-pages.mjs
--- chromium
columns 104 + 120 + 751 + 120 + 120px, caption below the rows, row 2 background rgb(243, 244, 246)
amount starts at x, tabular-nums: ₺420.00 1165, ₺553.00 1165, ₺1,115.50 1150.8, ₺240.00 1165, ₺520.00 1165, ₺2,118.50 1150.8
amount starts at x, proportional: ₺420.00 1166.4, ₺553.00 1166.6, ₺1,115.50 1161, ₺240.00 1166.7, ₺520.00 1166.8, ₺2,118.50 1158.3
--- webkit
columns 104 + 120 + 766 + 120 + 120px, caption below the rows, row 2 background rgb(243, 244, 246)
amount starts at x, tabular-nums: ₺420.00 1180, ₺553.00 1180, ₺1,115.50 1165.8, ₺240.00 1180, ₺520.00 1180, ₺2,118.50 1165.8
amount starts at x, proportional: ₺420.00 1181.4, ₺553.00 1181.6, ₺1,115.50 1176, ₺240.00 1181.7, ₺520.00 1181.8, ₺2,118.50 1173.3

The columns have the widths we gave the col elements, 104, 120, 120 and 120 pixels, and the rest goes to the books column. With tabular-nums the amounts of the same length, "₺420.00", "₺553.00" and "₺240.00", start at the same x, and so do the two four-digit amounts, at 1150.8. With proportional digits the four-digit ones start at 1161 and 1158.3: a 2.7 pixel difference, the kind of shift the eye notices when reading down a column.

The "My orders" Table: Zebra Rows, caption-side and a Sticky Header

Bookstore's order table sits inside a focusable scroll box, as in the HTML5 series:

<div class="table-scroll" tabindex="0" role="region" aria-labelledby="orders-caption">
    <table class="data-table orders">
        <caption id="orders-caption">Orders in 2026 (cancelled and returned orders are not counted)</caption>
        <colgroup>
            <col class="col-order">
            <col class="col-date">
            <col>
            <col class="col-status">
            <col class="col-total">
        </colgroup>
        <thead>
            <tr><th scope="col">Order</th><th scope="col">Date</th><th scope="col">Books</th><th scope="col">Status</th><th scope="col" class="num">Total</th></tr>
        </thead>
        <tbody>
            <tr><td>BK-1042</td><td>2026-11-14</td><td>Dune</td><td><span class="status status-delivered">Delivered</span></td><td class="num">₺420.00</td></tr>
/* data tables: shared borders, columns set by col elements, numbers on the right */
.data-table {
    inline-size: 100%;
    border-collapse: collapse;
    /* column widths come from the col elements and the first row, not from the longest content */
    table-layout: fixed;
    background: var(--color-surface);
}
.data-table caption {
    caption-side: bottom;
    padding-block: 0.5rem;
    color: var(--color-muted);
    text-align: start;
}
.data-table th,
.data-table td {
    padding: 0.5rem 0.75rem;
    border-block-end: 1px solid var(--color-border);
    text-align: start;
    vertical-align: top;
    overflow-wrap: break-word;
}
.data-table tbody tr:nth-child(even) { background: #f3f4f6; }
/* money: right aligned, every digit the same width, so the digits line up in columns */
.data-table .num {
    text-align: end;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.data-table tfoot { font-weight: 700; }
.col-order { inline-size: 6.5rem; }
.col-date { inline-size: 7.5rem; }
.col-status { inline-size: 7.5rem; }
.col-total { inline-size: 7.5rem; }

/* the table scrolls inside its own box (both axes), so the sticky header has something to stick to */
.table-scroll {
    max-block-size: 70vh;
    overflow: auto;
    border: 1px solid var(--color-border);
}
.data-table thead th {
    position: sticky;
    inset-block-start: 0;
    background: var(--color-surface);
    /* a collapsed border stays behind when the header sticks; a shadow moves with it */
    border-block-end: 0;
    box-shadow: inset 0 -2px 0 var(--color-border);
}

.status {
    display: inline-block;
    padding-inline: 0.5rem;
    border-radius: 999px;
    font-size: 0.875rem;
}
.status-delivered, .status-paid { background: #dcfce7; color: #166534; }
.status-shipped { background: #dbeafe; color: #1e40af; }
.status-cancelled, .status-returned { background: #f3f4f6; color: #4b5563; }

According to the first line of the measurement, the caption is below the rows, from caption-side: bottom, and the second row has the zebra background; the selector side of :nth-child(even) is in post 02 of the CSS3 series. Now the answer to the opening question:

--- chromium
sticky-separate header 0px from the top of the box, red line pixels in the top 60px: 900
sticky-collapse header 0px from the top of the box, red line pixels in the top 60px: 0
--- webkit
sticky-separate header 0px from the top of the box, red line pixels in the top 60px: 900
sticky-collapse header 0px from the top of the box, red line pixels in the top 60px: 0

The header stayed at the top in both boxes, but the red line came along only with the separate table, 900 pixels; with collapse, 0. In the collapsing model a border belongs to the line between two cells, not to the cell, and stays in place when the cell sticks. Bookstore's table uses collapse, so we draw the line under the header with an inset box-shadow; the shadow belongs to the cell itself and moves with it:

--- chromium
table box scrolled 400px: header 0px from the box top, line under the header 2430 px
overflow-x: auto box, page scrolled 600px: header top -435px in the viewport, computed overflow-y auto
--- webkit
table box scrolled 400px: header 0px from the box top, line under the header 2460 px
overflow-x: auto box, page scrolled 600px: header top -435px in the viewport, computed overflow-y auto

When the table's box is scrolled by 400 pixels, the header is at the top of the box and its line is still there. The second line tries a box with only overflow-x: auto, as in post 10 of the HTML5 series: even though we wrote visible for overflow-y, the computed value is auto, because when one axis is not visible the other one cannot be either, so the box is a scroll container. But its height is not limited, so the box itself never scrolls vertically; when the page scrolls by 600 pixels, the header moves with the page to -435 pixels. Sticky sticks to the nearest scroll container, and if that container never scrolls, the header never sticks. That is why Bookstore gives the box max-block-size: 70vh and scrolls the table in its own box on both axes. The general mechanics of sticky are in post 04 of the CSS3 series, the box's tabindex and role="region" in HTML5 10.

Printing: @media print, @page, break-inside and print-color-adjust

A printable invoice does not need a separate page; a print style sheet on the same HTML is enough:

/* print: paper size and margins for every printed page */
@page {
    size: A4;
    margin: 15mm;
}
@media print {
    /* navigation and buttons mean nothing on paper */
    .site-header,
    .print-button { display: none; }
    body { background: none; color: #000000; font-size: 11pt; }
    /* links: print the address after the text */
    a[href^="http"]::after { content: " (" attr(href) ")"; }
    /* the scroll box would cut the table on paper */
    .table-scroll { max-block-size: none; overflow: visible; border: 0; }
    .data-table thead th { position: static; }
    /* a row is never split between two pages; headings stay with what follows */
    .data-table tr { break-inside: avoid; }
    h1, h2 { break-after: avoid; }
    /* status colors carry meaning: print them even when "Background graphics" is off */
    .status { print-color-adjust: exact; }
}
<button type="button" class="print-button" onclick="window.print()">Print</button>
<dt>Payment</dt><dd><span class="status status-paid">Paid</span></dd>
<p class="invoice-note">Returns within 14 days, see <a href="https://bookstore.test/help/returns">our returns page</a>.</p>

The measurement has two parts. First the computed values with emulateMedia({ media: 'print' }) in both engines; then a real PDF, in Chromium only. Playwright's page.pdf() exists only in Chromium; I could not produce print output for WebKit or Firefox. The PDF is opened with pdf.js in the same Chromium page, which reads the page count, the page size, the text of every page and the light green pixels on the first page of the invoice:

// printBackground stays false: the default of the print dialog ("Background graphics" off)
const pdf = await p.pdf({ preferCSSPageSize: true });
node scripts/13-print.mjs
--- chromium (print media)
header display none, print button none, link ::after " (https://bookstore.test/help/returns)", .status print-color-adjust exact
--- webkit (print media)
header display none, print button none, link ::after " (" "https://bookstore.test/help/returns" ")", .status print-color-adjust exact
--- chromium, page.pdf()
invoice: 1 page, 594.96 x 841.92 pt
  "Bookstore My orders" header printed: false, "Print" button printed: false
  link printed as: "see our returns page (https://bookstore.test/help/returns)"
  "Paid" badge background pixels: 1543
  with print-color-adjust: economy: 0
orders: 3 pages
  page 1: header row yes, rows BK-1042 … BK-1029 (14)
  page 2: header row yes, rows BK-1028 … BK-1012 (17)
  page 3: header row yes, rows BK-1011 … BK-1003 (9)
tall rows, break-inside: auto  → 10 pages, rows cut in two: BK-1038, BK-1031, BK-1022, BK-1013, BK-1004
tall rows, break-inside: avoid → 11 pages, rows cut in two: none

In print media the header and the "Print" button are display: none, the link's ::after carries the address, and the status badge has print-color-adjust: exact. The computed values are the same in both engines; WebKit leaves attr() unresolved in the computed value. In the PDF the invoice is one page of 594.96 × 841.92 pt, so @page { size: A4; } was applied with preferCSSPageSize: true; without it Playwright uses its default Letter size. The header and the button are not on paper, and the link is printed as "our returns page (https://bookstore.test/help/returns)": the address of a link that cannot be clicked on paper can still be read.

The light green background of the "Paid" badge is 1543 pixels in the PDF. With print-color-adjust: economy, the default value, it is 0: to save ink, browsers do not print background colors and images by default, which is the "Background graphics" box in the print dialog. Write exact where the color carries information. According to browser-compat-data, Chrome and Safari do not print the background of body even with exact; the property applies only to its descendants. The same warning works the other way too: do not leave information to the background color alone; the "Paid" text inside the badge is readable on colorless paper as well.

The order table is three pages. Chromium prints the thead again at the top of every page, "header row yes", because the default display of thead is table-header-group. All 40 rows are printed once, 14 + 17 + 9. To see the effect of break-inside: avoid, I made the books column 36pt so that rows span several lines. With break-inside: auto, five of the nine page breaks in the ten page output cut a row in two, BK-1038, BK-1031 and so on; with avoid none was cut and the table grew by one page to 11. break-after: avoid on headings keeps a heading from staying alone at the bottom of a page. orphans and widows, which set how many lines of a paragraph must stay at the bottom or top of a page, are not Baseline because Firefox lacks them.

@page itself has been newly available since December 2024, when Safari 18.2 shipped size; according to browser-compat-data, Safari does not support the landscape and portrait keywords of size. Page margin boxes such as @top-center, used to print page numbers, have been in Chrome since 131 but are not Baseline.

In Production

Check the print output with the real print dialog too: users can change the paper size, the margins and the "Background graphics" box; @page is a suggestion. If the invoice PDF is produced on the server, for an e-mail attachment or an accounting archive, turning the same print style sheet into a PDF with headless Chromium is cheaper than maintaining a separate PDF template; the page.pdf() call in this post is the smallest version of that.

With table-layout: fixed, printing long tables becomes predictable too: on the narrow width of A4, an auto table can run off the paper because of one long cell. Turning a table into cards on narrow screens with display: block or grid breaks the table semantics; post 10 of the HTML5 series explains why and how to fix it. A scrolling box is usually the better answer.

A table with a sticky header that scrolls inside its own box brings two scrollbars, the page's and the box's. max-block-size: 70vh keeps the box smaller than the screen, so it does not fight the page scrollbar; the scrollbar-gutter from the previous post can be used on the box as well.

Common Pitfalls

First, relying on the border of a sticky header in a table with border-collapse: collapse. When the header sticks, the border stays behind; draw the line with box-shadow.

Second, letting long content without break points widen the whole table with table-layout: auto. fixed with col widths keeps the columns in place.

Third, aligning the money column to the right and forgetting tabular-nums, or expecting it to work in a font without the tnum feature.

Fourth, giving information only through a background color. Backgrounds are not printed by default; add text, and write print-color-adjust: exact where needed.

Fifth, using a box with only overflow-x: auto for a sticky header. If the box never scrolls vertically, the header has nothing to stick to.

Baseline Status

node scripts/baseline.mjs table font-variant-numeric sticky-positioning print-color-adjust page-setup page-breaks widows-orphans
table                      widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
font-variant-numeric       widely 2022-07-15  Chrome 52, Firefox 34, Safari 9.1, iOS 9.3
sticky-positioning         widely 2022-03-19  Chrome 56, Firefox 59, Safari 13, iOS 13
print-color-adjust         newly 2025-05-01   Chrome 136, Firefox 97, Safari 15.4, iOS 15.4
page-setup                 newly 2024-12-11   Chrome 15, Firefox 95, Safari 18.2, iOS 18.2
page-breaks                widely 2021-07-29  Chrome 50, Firefox 65, Safari 10, iOS 10
widows-orphans             not Baseline       Chrome 25, Firefox -, Safari 1.3, iOS 1
FeatureStatusNote
Table properties: border-collapse, table-layout, caption-sideWidely availableValues such as caption-side: top-outside are non-standard
font-variant-numericWidely availableNo effect without tnum in the font
Sticky thWidely availableA collapsed border does not stick
@page and sizeNewly available (2024-12)No landscape/portrait in Safari
break-inside, break-afterWidely availableValues such as avoid-page are not
print-color-adjustNewly available (2025-05)-webkit- prefix before Chrome 136
orphans, widowsNot BaselineMissing in Firefox

Wrapping Up the Series

In thirteen posts we styled every Bookstore screen property by property. In the font and text block, 01–03: a self-hosted variable font, a readable link underline, line breaking and multi-line truncation; in the box's look, 04–05: background layers, gradients, borders, shadows and the focus ring; in visual effects, 06–08: transforms, filters, blend modes, clip-path and masks; in images and generated content, 09–10: the img reset, SVG icons, counters and list markers; in the last block, 11–13: scrolling, form controls, tables and printing.

The same three habits paid off throughout the series. Know what a shorthand resets: font, background, border. Look up the Baseline status of every property and add what is not Baseline as progressive enhancement; most of the time the reason is one sub-feature in one browser, Chrome Android for accent-color, Safari for overscroll-behavior, Safari's prefix for user-select. And measure: in this series measurement caught a flex strip that squeezed its images, a check mark that disappeared in forced colors and a border that did not follow a sticky header; none of the three was obvious at first glance in a screenshot.

This series answered "which property do I write for this look?". The question "why did CSS decide it this way?", meaning the cascade, specificity, the box model, layout with Flexbox and Grid, responsive design, custom properties, animation and a summary of what arrived in 2023–2026, is the subject of the site's CSS series (CSS3: Flexbox, Grid and Modern CSS). If you have finished this series, reading that one from its first post is the shortest way to see why every rule you wrote here works.

A Small Experiment

★☆☆ Remove tabular-nums from .num in the order table and look at how the start positions printed by 13-pages.mjs spread; then find out with the same measurement whether Fraunces supports tnum.

★★☆ Give the invoice a larger top margin on the first page with @page :first and add a line to 13-print.mjs that measures where the text starts on the first page.

★★★ Write a "Print only open orders" option for the order table that hides completed orders when printing, with a checkbox and :has(), without JavaScript, and measure how many pages remain in the PDF.

The series ends here. When you are not sure how a property behaves, write a small lab page as in this series and measure it in two engines; the browser is a more reliable source than any document.