Frozen Columns Table — Sticky Horizontal Pinned Columns HTML CSS JS

Frozen Columns Table · Tables · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Real position:sticky columns
Frozen th and td elements use sticky with a left offset — not a duplicated overlay table.
Per-cell sticky application
Every frozen cell in a column carries the sticky class, since CSS has no column-level sticky selector.
Stacked z-index layering
Frozen cells sit above scrolling ones with distinct z-index tiers for header versus body.
Freeze-pane edge shadow
A gradient on the last frozen column's edge signals the scroll boundary, spreadsheet-style.
Unified row hover
Hover styling spans frozen and scrolling cells so the row reads as one continuous highlight.
Sticky header too
The header row is simultaneously sticky vertically, so column labels stay visible on tall tables.
Right-aligned numeric columns
Quarterly values are right-aligned with tabular numerals for easy scanning down a column.
Configurable freeze count
Freeze one, two, or more leading columns by adding matching fct-frozen-N classes and left offsets.

About this UI Snippet

Frozen Columns Table — Pin Leading Columns With Real position:sticky

Screenshot of the Frozen Columns Table snippet rendered live

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:

text
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

  1. 1
    Paste HTML, CSS, and JSA revenue table with eight quarterly columns renders inside a horizontally scrollable card.
  2. 2
    Scroll the table rightThe Account and Owner columns stay pinned in place while the quarter columns slide underneath.
  3. 3
    Hover a rowThe highlight spans both the frozen and scrolling portions of the row together.
  4. 4
    Watch the edge shadowA subtle gradient on the second frozen column marks the freeze-pane boundary as content scrolls under it.
  5. 5
    Add 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.
  6. 6
    Swap in your own dataReplace the ACCOUNTS array — each row's cells render from a plain array of values.

Real-world uses

Common Use Cases

Financial and revenue dashboards
Keep account names visible while scanning many quarters or months of figures.
Spreadsheet-style admin tools
Pair with a resizable columns table for an Excel-like internal tool.
Wide product or SKU catalogs
Freeze product name and SKU while scrolling through per-warehouse stock columns.
Comparison matrices
Keep the row label frozen in a wide comparison table with many feature columns.
Timesheet and scheduling grids
Pin employee name while scrolling across days or weeks of shift data.
Learning sticky positioning
A clear reference for combining horizontal and vertical position:sticky in one table.

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.