webdevpuneet.com

Dense Feature Comparison Matrix — Sticky Rows & Columns, No Library

Dense Feature Comparison Matrix · Pricing · Plain HTML, CSS & JS · Live preview

CategoryPricing

Copy & paste

Source Code

<div class="cmg-wrap">
  <div class="cmg-intro">
    <h2>Compare every plan, every feature</h2>
    <p>Scroll down and across — the feature column and the plan header both stay pinned.</p>
  </div>
  <div class="cmg-scroll" id="cmgScroll">
    <table class="cmg-table">
      <thead>
        <tr>
          <th class="cmg-corner">Feature</th>
          <th>Free</th>
          <th class="cmg-featured">Starter</th>
          <th>Business</th>
          <th>Enterprise</th>
        </tr>
      </thead>
      <tbody>
        <tr><th scope="row">Projects</th><td>3</td><td class="cmg-featured">25</td><td>Unlimited</td><td>Unlimited</td></tr>
        <tr><th scope="row">Team members</th><td>1</td><td class="cmg-featured">5</td><td>25</td><td>Unlimited</td></tr>
        <tr><th scope="row">Storage</th><td>1 GB</td><td class="cmg-featured">50 GB</td><td>500 GB</td><td>Custom</td></tr>
        <tr><th scope="row">API access</th><td class="cmg-dash">—</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">API rate limit</th><td class="cmg-dash">—</td><td class="cmg-featured">60 req/min</td><td>600 req/min</td><td>Custom</td></tr>
        <tr><th scope="row">SSO / SAML</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">Audit logs</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">Role-based permissions</th><td class="cmg-dash">—</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">Custom domains</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td class="cmg-check">✓</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">Uptime SLA</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td>99.9%</td><td>99.99%</td></tr>
        <tr><th scope="row">Support</th><td>Community</td><td class="cmg-featured">Email</td><td>Priority</td><td>Dedicated CSM</td></tr>
        <tr><th scope="row">Onboarding</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td>Guided setup</td><td>White-glove</td></tr>
        <tr><th scope="row">SOC 2 report</th><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td class="cmg-dash">—</td><td class="cmg-check">✓</td></tr>
        <tr><th scope="row">Monthly price</th><td>$0</td><td class="cmg-featured">$29</td><td>$99</td><td>Custom</td></tr>
      </tbody>
    </table>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Genuinely both-axes sticky
Header row and first column stay pinned at the same time.
Correct z-index layering
Corner above header above first column above body cells.
border-collapse: separate
Avoids collapsed-border sticky rendering bugs.
4 plans × 14 feature rows
Dense enough to actually need the pattern.
Semantic table markup
scope="row" row headers keep it screen-reader friendly.
Featured-plan column styling
A highlighted Starter column draws the eye.
Zebra striping
Alternating row backgrounds aid horizontal scanning.
Row-hover highlight
Small JS enhancement to track a row while scrolling.

About this UI Snippet

Dense Feature Comparison Matrix — Sticky Header Row AND Sticky First Column, Together

Screenshot of the Dense Feature Comparison Matrix snippet rendered live

A comparison table with four or more plans and a dozen-plus feature rows is too big to fit on screen at once, which normally forces a visitor to scroll and lose track of which row or which plan column they were even looking at. This snippet solves both problems simultaneously with pure CSS position: sticky — the plan header row stays pinned while scrolling down, the feature-name column stays pinned while scrolling right, and the top-left corner cell stays pinned in both directions at once, genuinely, not just one axis dressed up to look like both.

Why sticky-both-axes is harder than it looks

Making a header row sticky (position: sticky; top: 0) is common and well-documented. Making a *first column* sticky (position: sticky; left: 0) on its own is also common. Doing both at the same time, correctly, requires getting three separate rules right together — miss any one and either the corner cell scrolls away with the header, or the first-column cells overlap the header incorrectly, or z-index fighting makes cells render behind each other during scroll:

1. Every thead th gets position: sticky; top: 0 so the whole header row pins to the top of the scroll container. 2. Every tbody th (the first-column feature-name cells) gets position: sticky; left: 0 so the whole first column pins to the left. 3. The single corner cell — .cmg-corner, the top-left th in thead — gets both top: 0 and left: 0 simultaneously, plus the highest z-index of the three layers, so it stays fixed in both directions and always renders above the header row scrolling underneath it and the first column scrolling underneath it.

The z-index layering that makes it actually work

Without careful z-index ordering, sticky cells render in DOM order as they scroll under each other, producing visible seams or the wrong cell on top. This snippet layers it explicitly: the corner cell at z-index: 3 (highest, since it overlaps both other sticky layers), the header row at z-index: 2, the first column at z-index: 1, and ordinary body cells at the default stacking level. Each sticky cell also needs its own explicit background, since a transparent sticky cell lets the scrolling content behind it show through — an easy, common mistake that makes "sticky" cells look broken even when the positioning itself is correct.

border-collapse: separate, not collapse

Sticky positioning on table cells is unreliable with border-collapse: collapse in some browser rendering engines, because collapsed borders are shared between adjacent cells in ways that interact awkwardly with sticky's own layout calculations. This table uses border-collapse: separate; border-spacing: 0 and draws borders manually via border-bottom/border-right on individual cells instead, which keeps the sticky behavior consistent and predictable across browsers.

A real 4×14 matrix, not a toy example

Four plans (Free, Starter, Business, Enterprise) across 14 feature rows — from project and team limits through SSO, audit logs, uptime SLA, and monthly price — is dense enough that sticky-both-axes genuinely matters; a small 2×4 table wouldn't demonstrate the problem this pattern solves. The scroll container is capped with max-height and overflow: auto, so both scrolling axes are real, not simulated.

Customizing it

Add rows or columns freely — the sticky rules apply by element type (thead th, tbody th, .cmg-corner), not by index, so the layout keeps working at any size. Pair it with pricing feature table for a simpler, non-scrolling variant, or comparison table for a general-purpose base.

Step by step

How to Use

  1. 1
    Scroll down inside the tableThe Feature/plan header row stays pinned to the top.
  2. 2
    Scroll right inside the tableThe feature-name column stays pinned to the left.
  3. 3
    Scroll diagonallyThe corner cell stays fixed in both directions simultaneously.
  4. 4
    Hover a rowA lightweight highlight helps track a feature across many columns.
  5. 5
    Add a plan columnAdd a th to the header and a matching td to every row — sticky rules apply automatically.
  6. 6
    Add a feature rowAdd a tr with a tbody th first cell — it inherits the sticky first-column rule.

Real-world uses

Common Use Cases

Dense SaaS plan comparisons
Offer a dense alternative to a pricing feature table, with four plans and fourteen rows where header row and first column both stay pinned at once.
Enterprise sales collateral
Compare against enterprise pricing tiers in a document buyers can scroll without losing track of which plan or feature row they are reading.
Product specification sheets
Present any large row-by-column technical comparison, using border-collapse: separate to avoid the sticky rendering bugs that collapsed borders can cause.
Competitor comparison pages
Extend the plan columns to include competitors, with a correct z-index order of corner above header above first column above body cells.
Admin dashboards and API tier docs
Reuse the sticky-both-axes pattern for internal data grids, or compare plan and API tier limits at a glance in documentation.
Related: Commitment Length Discount Ladder
See the Commitment Length Discount Ladder for a related pricing pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Both axes genuinely work at once. thead th cells use position: sticky with top: 0 (sticky vertically), tbody th cells use position: sticky with left: 0 (sticky horizontally), and the single corner cell — the top-left th — uses both top: 0 and left: 0 together with the highest z-index of the three layers, so it stays fixed in both directions simultaneously as you scroll diagonally.

As you scroll, the corner cell sits at the intersection where the sticky header row and the sticky first column would otherwise overlap each other. Without the corner having the highest z-index, whichever layer comes later in the DOM would render on top during scroll, causing the corner cell to visually disappear under the header or the first column instead of staying correctly on top of both.

position: sticky on table cells behaves unreliably in some browsers when combined with border-collapse: collapse, because collapsed borders are shared between adjacent cells in a way that can interfere with sticky's positioning calculations. Using border-collapse: separate with border-spacing: 0, and drawing borders manually on individual cells, avoids that class of bug entirely.

A sticky cell without its own background is transparent, so as the rest of the table scrolls underneath it, that scrolling content shows through the "pinned" cell — which looks broken even though the positioning itself is technically correct. Every sticky cell in this table sets an explicit background so it visually occludes whatever scrolls beneath it.

No — the sticky rules are written against element types (thead th, tbody th, and the .cmg-corner class) rather than specific row or column indices, so adding a new plan column (a th plus a matching td in every row) or a new feature row (a tr with a tbody th first cell) automatically inherits the correct sticky behavior with no other changes needed.