Frozen Columns Table — Sticky Horizontal Pinned Columns HTML CSS JS
Frozen Columns Table · Tables · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Frozen Columns Table — Pin Leading Columns With Real position:sticky

A wide table with a dozen quarters or metrics across the top is unreadable the moment you scroll right and lose sight of which row you're even looking at. Spreadsheets solve this with "freeze panes" — the leading identifying columns (name, id, owner) stay put while the rest of the data scrolls underneath. This snippet builds that with genuine position: sticky on the frozen cells themselves, not a fake overlay or a second table drawn on top.
Sticky columns, not a sticky header
It's easy to confuse this with a sticky header table, which pins rows vertically as you scroll down. This is the perpendicular problem: pinning columns horizontally as you scroll right, inside a container with overflow-x: auto. Both the frozen <th> and every frozen <td> in that column get position: sticky with a left offset — the header uses fct-frozen-1 at left: 0 and fct-frozen-2 at left: 160px (the first column's width), so the second frozen column sits flush against the first rather than overlapping it.
Why every frozen cell needs its own sticky rule, not just the column
CSS has no way to say "this column is sticky" — sticky positioning is a per-element property, so the class has to be applied to every <th> and every <td> in that column, each with the same left value. That's why the JS building each row's markup writes fct-frozen fct-frozen-1 and fct-frozen fct-frozen-2 onto the corresponding <td>s directly rather than relying on a single rule targeting a column position — sticky only works cell by cell.
Layering frozen cells above scrolling ones
As scrolling cells slide underneath the frozen columns, the frozen cells need an opaque background and a higher z-index or they'd let the scrolling content show through. The header's frozen cells get z-index: 4 (above both scrolling header cells at z-index: 3 and body cells), and body frozen cells get z-index: 2, so the visual stacking always reads correctly no matter how far right you scroll.
A subtle edge to signal the seam
Without a visual cue, it's not obvious to the eye where "frozen" ends and "scrolling" begins. A small ::after gradient shadow on the second frozen column's right edge fades from dark to transparent, mimicking the drop-shadow spreadsheet apps use at a freeze-pane boundary — a cheap but effective affordance.
Hover state that spans both zones
Because frozen and scrolling cells are separate DOM elements per row, a row hover needs tbody tr:hover td and an explicit override for the frozen cells' background (tbody tr:hover .fct-frozen-1) so the whole row — frozen and scrolling parts alike — highlights together, keeping the illusion of one continuous row intact.
Customizing it
Freeze one column instead of two by dropping the fct-frozen-2 class and adjusting widths, or freeze three by adding a fct-frozen-3 at left: 280px. Pair it with resizable columns or a column drag reorder for a fuller spreadsheet-grade table.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Rather than puzzling out the sticky offsets by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why every frozen td in a column needs its own position:sticky rule rather than one rule targeting "the column," and why the second frozen column's left offset must equal the first frozen column's width in pixels. The same assistant can help you extend it — ask it to make the freeze count configurable via a data attribute, add a resize handle so a frozen column's width (and therefore the next column's left offset) can change dynamically, or combine this with sticky header rows for freezing in both directions at once, which this snippet already does but is worth verifying interacts correctly as you add more frozen columns. 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 "frozen columns" data table in plain HTML, CSS, and JavaScript with no library — a wide table where the first two columns stay visually pinned in place while the rest of the table's columns scroll horizontally underneath them.
Requirements:
- Wrap the table in a container with overflow-x: auto and give the table a min-width wider than the container so it actually needs horizontal scrolling.
- Use real CSS position: sticky (not a duplicated overlay table or JavaScript-driven repositioning) on both the header cell and every body cell of the first two columns, with a left offset — the first frozen column at left: 0 and the second at left equal to the first column's pixel width, so the two frozen columns sit flush against each other with no gap or overlap.
- Give the frozen cells a higher z-index than the scrolling cells and an explicit opaque background color, so as columns slide underneath during a horizontal scroll they do not visually show through the frozen ones.
- Also make the header row sticky vertically (position: sticky, top: 0) so column labels remain visible if the table is tall enough to scroll vertically too, and make sure the header's frozen cells have the highest z-index of all so they stay above both scrolling header cells and frozen body cells.
- Add a subtle right-edge shadow or gradient on the last frozen column to visually mark the boundary between frozen and scrolling content, similar to a spreadsheet's freeze-pane divider.
- Make row hover styling apply consistently across both the frozen and scrolling cells of a row, so hovering highlights the entire row as one visual unit despite frozen and scrolling cells being separate elements.
- Render the table rows from a plain JavaScript array of row objects (name, owner, and several numeric values), building the row's HTML string in a loop rather than hardcoding every row by hand.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA revenue table with eight quarterly columns renders inside a horizontally scrollable card.
- 2Scroll the table rightThe Account and Owner columns stay pinned in place while the quarter columns slide underneath.
- 3Hover a rowThe highlight spans both the frozen and scrolling portions of the row together.
- 4Watch the edge shadowA subtle gradient on the second frozen column marks the freeze-pane boundary as content scrolls under it.
- 5Add or remove a frozen columnApply fct-frozen plus a fct-frozen-N class (with the right left offset) to freeze more or fewer leading columns.
- 6Swap in your own dataReplace the ACCOUNTS array — each row's cells render from a plain array of values.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Sticky positioning is applied per element, not per column — CSS has no selector that means "this entire column." The header's th and every td in that column across every row must each carry the fct-frozen class with a matching left offset, or only some rows would freeze correctly while others scrolled away.
Add a fct-frozen-3 rule with left set to the combined width of the first two frozen columns (e.g. 280px if they're 160px and 120px), apply fct-frozen fct-frozen-3 to the third column's th and every td, and give it its own z-index and background so it layers correctly above scrolling cells.
Without one, scrolling cells sliding underneath would show through the "frozen" cells as you scroll, breaking the illusion. Setting an explicit background color on every frozen cell (and a distinct hover background) keeps the pinned columns visually solid at all scroll positions.
Yes — position:sticky and overflow-x:auto both work with native touch scrolling, so a user can swipe the table horizontally and the frozen columns stay pinned exactly as they do with mouse-wheel or trackpad scrolling.
Render the table exactly the same way — the sticky behavior is pure CSS and needs no JavaScript beyond building the row markup. In React, map ACCOUNTS to JSX rows with the same fct-frozen classes; in Vue or Angular use v-for or *ngFor. The CSS ports unchanged.