WEB DEVELOPMENT · CSS TUTORIAL
Create a Sticky Table Header with CSS and Semantic HTML
Build a readable scrolling table without cloning the header or turning table rows into disconnected blocks.

A long table becomes harder to read when its column headings disappear above the scroll position. A sticky header preserves that context while the rows continue underneath it. For a straightforward table, CSS can do the job without a second copy of the header, width-synchronization scripts or a custom scrolling system.
The important choice is where the table should scroll. This tutorial places it inside a height-limited region that can scroll both vertically and horizontally. The headings stick to the top of that region. You can open the standalone table demo to inspect the pattern with thirty rows of clearly labeled sample data.
Start with a real data table
Keep the caption, header group and body group in ordinary table markup. Column headers use header cells with column scope. In the example, the first cell of each body row identifies that row and therefore uses row scope. The relationships remain available even when the styling is removed.
Do not use a table merely to arrange unrelated page elements. This pattern is for information whose rows and columns belong together. More complex headers spanning several rows or columns need additional care; a single-row example should not be presented as a complete recipe for every possible financial report or product configurator.
<h2 id="inventory-heading">Example inventory</h2>
<p id="inventory-help">Scroll inside the table to see additional rows and columns.</p>
<div class="table-scroll" tabindex="0" role="region"
aria-labelledby="inventory-heading" aria-describedby="inventory-help">
<table class="inventory-table">
<caption>Illustrative inventory data, not live stock information</caption>
<thead>
<tr><th scope="col">Part</th><th scope="col">Description</th>
<th scope="col" class="numeric">Quantity</th>
<th scope="col" class="numeric">Unit price (USD)</th></tr>
</thead>
<tbody>
<tr><th scope="row">DEMO-001</th><td>Example bracket</td>
<td class="numeric">4</td><td class="numeric">25.00</td></tr>
<!-- Additional rows retain the same table structure. -->
</tbody>
</table>
</div>
The surrounding region has a name and a short scrolling instruction. Making it keyboard-focusable gives a keyboard user a place to direct scrolling. The region does not replace the table’s own semantics, and its label should identify the collection rather than repeat a vague phrase such as content area.
Constrain the scroll region
A maximum height creates a point at which vertical scrolling becomes useful. Without enough content or a height limit, the region may simply grow with its rows, leaving nothing to scroll inside it. In that case the header may appear not to be sticky even though the CSS declaration is present.
The minimum table width preserves space for the example columns on a narrow screen. The wrapper contains the resulting horizontal overflow so the whole page does not have to become wider. Choose the minimum from the actual columns you need to display, rather than copying forty-four rem into every project.
Make header cells sticky
Apply sticky positioning to the cells in the header group and give them a top offset. An opaque background prevents body text from showing through as it passes beneath. A small stacking level places the headings above the ordinary rows inside this component.
.table-scroll {
max-height: 22rem;
overflow: auto;
isolation: isolate;
border: 1px solid #d8d0b3;
}
.inventory-table {
width: 100%;
min-width: 44rem;
border-collapse: separate;
border-spacing: 0;
}
.inventory-table caption { text-align: left; padding: 1rem; }
.inventory-table th,
.inventory-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e2ddc9;
background: #fff;
}
.inventory-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #fff1b8;
box-shadow: 0 1px 0 #b3a67c;
}
.inventory-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
.table-scroll:focus-visible { outline: 3px solid #926500; outline-offset: 3px; }
The example uses separated borders with zero spacing and a shadow at the lower edge of the header. That avoids relying on a collapsed shared border to provide the visible sticky boundary. The header still belongs to the same table, so the browser continues to calculate column widths from the combined content.
Understand which ancestor owns the movement
Sticky positioning interacts with its ancestor scrolling context and containing block. An unexpected overflow rule on a wrapper can change where the browser tries to keep the header visible. When the result is wrong, inspect the surrounding containers rather than adding ever larger stacking numbers.
There are two different designs: a header that remains visible while the page scrolls and one that remains visible while a local table region scrolls. This tutorial implements the second. Removing the height-limited region without reconsidering the ancestors changes the design. Decide which movement the reader will actually use before adapting the CSS.
Keep one header and one set of column widths
Cloning the header into another table creates extra work. The two tables can disagree about widths after a font loads, text wraps or the viewport changes. Interactive header controls such as sorting buttons would also need their state and accessible names kept consistent between the copies.
A native sticky header avoids that duplication. Keep the body rows in their normal table layout rather than switching them to display blocks solely to add scrolling. The wrapper can own the overflow while the table keeps its ordinary relationships. If your application already uses a complex grid widget, check its supported behavior before combining a second independent sticky implementation with it.
Handle additional sticky rows deliberately
This example has one heading row. With two sticky header rows, assigning the same top offset to both would make them overlap. You need a strategy for the second row’s offset that accounts for the first row’s actual height, including text wrapping and zoom.
A fixed pixel guess can work in a controlled layout and fail as soon as a label wraps. For a complex report, either simplify the header, use a layout that treats the group appropriately, or measure the necessary offset and update it when the header changes. That is a separate requirement from the one-row pattern shown here.
Preserve keyboard and screen-reader usability
A visitor should be able to focus the scroll region, move through its content and see the focus outline. If the header includes controls, those controls need clear names and ordinary keyboard operation. Sticky placement should not hide the focused element beneath another overlay.
Do not announce each scroll movement through a live region. The table’s caption and header relationships already supply the structural information. Keep numbers aligned consistently, label units and avoid using background color as the only indication of a state. Sticky positioning can preserve context, but it cannot repair ambiguous data labels.
Test the table as content changes
Try long descriptions, an empty result, enough rows to overflow and too few rows to overflow. Increase the text size and inspect the horizontal scrolling behavior. A narrow-screen check should confirm that the wrapper contains the width while the page around it remains usable.
Also inspect the print layout if readers need printed reports. A scrolling region’s maximum height can clip useful content in print unless a print rule removes that constraint. The standalone demo focuses on screen reading; production pages should define the print behavior that their audience actually needs.
Use a small component before a complicated grid
For many lists and inventory tables, this combination of semantic markup and local overflow is sufficient. Keep class names scoped so the sticky rule does not unexpectedly affect every header cell on the website. If you later need frozen columns, virtualization or editable cells, treat those as additional features with their own interaction and testing requirements.
W3C’s table tutorial explains structural relationships, while MDN documents sticky positioning. For a related interaction example, see synchronizing two scrollable elements. Keep that extra JavaScript for cases that actually require two independent panes; this header uses the browser’s native table layout.