More Tables Snippets
Sticky Header Table — Frozen Panes HTML CSS Snippet
Sticky Header Table · Tables · Plain HTML & CSS · Live preview
What's included
Features
position: sticky; top: 0 inside the scroll container, so column labels stay visible while rows scroll.position: sticky; left: 0, keeping the row label visible during horizontal scroll.top: 0 and left: 0 at the highest z-index, anchoring both frozen panes.overflow: auto plus width: max-content; min-width: 100% enables both vertical and horizontal scrolling that fills the card.border-collapse: separate with per-cell borders and a 1px ::after edge avoids the broken sticky behaviour of collapsed borders.position: sticky — no scroll listeners, no layout thrash, fully accessible.About this UI Snippet
Sticky Header Table — Frozen Header Row & Frozen First Column With position:sticky

Wide data tables are unreadable the moment you scroll: the column headers disappear off the top and you lose track of which row you are on as the first column scrolls off the left. The fix every dashboard and admin panel needs is frozen panes — a header row pinned to the top and a first column pinned to the left, both staying put while the rest of the table scrolls in two dimensions. This snippet implements that in pure HTML and CSS using position: sticky, with zero JavaScript.
The two-axis sticky setup
The table lives in a .sht-scroll container with overflow: auto and a fixed max-height, which is what creates the scroll context. Three sticky rules do the work: the header cells use position: sticky; top: 0 so they pin to the top of that scroll container; the first-column cells use position: sticky; left: 0 so they pin to the left; and the top-left corner cell uses both top: 0 and left: 0 so it stays locked in place as the anchor of both frozen panes.
Why z-index ordering matters
Sticky cells overlap as you scroll, so stacking order is critical. The header row sits at z-index: 2, the frozen first column at z-index: 1, and the corner cell at z-index: 3 so it stays above both when they cross. Without this ordering the body cells would bleed over the frozen header, or the header would slide under the first column — the classic broken-frozen-pane look. Each sticky cell also needs an opaque background (transparent sticky cells let scrolled content show through), which is why the header is #f8fafc and the first column is white.
Border-collapse caveat
position: sticky does not work reliably with border-collapse: collapse because collapsed borders are owned by the table, not the cells. This snippet uses border-collapse: separate; border-spacing: 0 and draws row dividers with per-cell border-bottom, plus a 1px ::after on the frozen column to render the vertical edge that visually separates it from the scrolling area.
Sizing for horizontal scroll
The table uses width: max-content; min-width: 100% so it grows as wide as its content needs (triggering horizontal scroll) but still fills the container when there are few columns. Numeric columns are right-aligned with tabular-nums for clean figure alignment, while the frozen name column is left-aligned.
The result is a robust, dependency-free data grid that scrolls in both directions with headers and the key column always visible. Pair this with a sortable table for column sorting, a pagination table for paging, or a data table with search and row actions.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to puzzle out the z-index stacking order by trial and error. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the corner cell needs both top and left stickiness at z-index 3 while the header sits at 2 and the frozen column at 1, and why border-collapse separate is required instead of the usual collapse. The same assistant can help optimize it — for instance whether a table with hundreds of rows should virtualize its body instead of relying on the browser to lay out every row, or whether the frozen-column shadow effect needs a scroll listener at all. It's also useful for extending the table: ask it to freeze a second column, add a scrolled-state box-shadow cue on the frozen edges, or make columns resizable while keeping the sticky behavior intact. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a data table with a frozen header row and a frozen first column in plain HTML and CSS using only position: sticky — no JavaScript, no virtual scrolling library.
Requirements:
- A scroll container with a fixed max-height and overflow auto wrapping a table sized with width: max-content and min-width: 100%, so it can scroll both vertically and horizontally.
- The table must use border-collapse: separate with border-spacing: 0, not border-collapse: collapse, and draw row separators using per-cell border-bottom, because collapsed borders break position: sticky on table cells.
- All header cells must be position: sticky with top: 0 so they remain pinned to the top of the scroll container while the body scrolls vertically underneath them.
- Every row's first cell (the row label) must be position: sticky with left: 0 so it remains pinned to the left edge while the remaining columns scroll horizontally.
- The single top-left corner cell must be position: sticky with both top: 0 and left: 0 simultaneously, so it stays anchored in both directions as the visual anchor of the two frozen panes.
- Assign explicit, correctly ordered z-index values so the corner cell always renders above the header row, and the header row always renders above the frozen column, preventing scrolled body cells from bleeding over either frozen edge.
- Every sticky cell must have an explicit opaque background color, since transparent sticky cells let scrolled content show through underneath them.
- Add a thin vertical divider (e.g. a 1px pseudo-element edge) on the frozen column so its boundary with the scrolling area is visually clear.Step by step
How to Use
- 1Paste HTML, CSS, and JSA "Team performance" table appears inside a fixed-height, scrollable card with many columns and rows.
- 2Scroll downThe header row (Role, Deals, Revenue…) stays pinned at the top while the rows scroll beneath it.
- 3Scroll rightThe first "Member" column stays pinned to the left while the metric columns scroll horizontally.
- 4Watch the cornerThe top-left "Member" header cell stays locked in both directions as the anchor of the frozen panes.
- 5Hover a rowThe whole row — including the frozen name cell — highlights together, so the pinned column tracks the hovered row.
- 6Adjust the freeze heightChange
max-heighton.sht-scrollto control how many rows show before vertical scrolling kicks in.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The two most common causes: (1) border-collapse: collapse breaks sticky on table cells — switch to border-collapse: separate; border-spacing: 0 and draw borders per cell (this snippet does). (2) An ancestor has overflow: hidden/auto other than your intended scroll container, which captures the stickiness. Also ensure the scroll container has a constrained height/width so there is something to scroll within.
Make each additional column sticky with left set to the cumulative width of the columns before it (e.g. second frozen column left: 160px if the first is 160px wide). Give them descending z-indexes and opaque backgrounds. Because left offsets must be exact, fixed widths on the frozen columns make this reliable.
Pure CSS cannot detect scroll position, so add a tiny scroll listener that toggles a class on the container when scrollLeft > 0 (and scrollTop > 0), then apply a box-shadow to the frozen column/header in that state. This "scrolled" shadow is a nice cue that content is hidden behind the frozen panes, used by Google Sheets and Airtable.
Yes — it is a real semantic <table> with <thead>, <th> headers (including row headers via <th scope="row">, which you should add), so screen readers announce the structure correctly. position: sticky is purely visual and does not change the DOM order or semantics, so assistive tech is unaffected by the freezing.
It is mostly CSS, so it ports almost verbatim: render the <table> from your data with the same class names and position: sticky rules. In React, map rows from an array and add the sticky classes; in Vue use v-for; in Angular *ngFor. No lifecycle hooks are needed since there is no JavaScript — only add a hook if you implement the optional scroll-shadow cue.