Open demo

CSS code

HTML
<div class="tcard">
  <div class="tscroll" tabindex="0" role="region" aria-label="Regional revenue, scrollable">
    <table class="tbl">
      <caption class="sr-only">Regional revenue by month, in pounds</caption>
      <thead><tr>
        <th scope="col" class="pin">Month</th><th scope="col" class="num">UK</th><th scope="col" class="num">Germany</th><th scope="col" class="num">France</th><th scope="col" class="num">Spain</th><th scope="col" class="num">Poland</th><th scope="col" class="num">Nordics</th><th scope="col" class="num">Total</th>
      </tr></thead>
      <tbody>
        <tr><th scope="row" class="pin">January</th><td class="num">£48,200</td><td class="num">£31,940</td><td class="num">£22,180</td><td class="num">£14,760</td><td class="num">£9,420</td><td class="num">£18,330</td><td class="num">£144,830</td></tr>
        <tr><th scope="row" class="pin">February</th><td class="num">£51,470</td><td class="num">£33,110</td><td class="num">£20,940</td><td class="num">£15,880</td><td class="num">£10,150</td><td class="num">£19,020</td><td class="num">£150,570</td></tr>
        <tr><th scope="row" class="pin">March</th><td class="num">£56,930</td><td class="num">£35,780</td><td class="num">£24,610</td><td class="num">£17,240</td><td class="num">£11,890</td><td class="num">£21,460</td><td class="num">£167,910</td></tr>
        <tr><th scope="row" class="pin">April</th><td class="num">£54,180</td><td class="num">£34,220</td><td class="num">£23,050</td><td class="num">£16,410</td><td class="num">£10,930</td><td class="num">£20,180</td><td class="num">£158,970</td></tr>
        <tr><th scope="row" class="pin">May</th><td class="num">£59,640</td><td class="num">£37,910</td><td class="num">£26,340</td><td class="num">£18,720</td><td class="num">£12,470</td><td class="num">£22,890</td><td class="num">£177,970</td></tr>
        <tr><th scope="row" class="pin">June</th><td class="num">£62,310</td><td class="num">£39,440</td><td class="num">£27,880</td><td class="num">£19,560</td><td class="num">£13,220</td><td class="num">£24,110</td><td class="num">£186,520</td></tr>
        <tr><th scope="row" class="pin">July</th><td class="num">£58,770</td><td class="num">£36,890</td><td class="num">£25,430</td><td class="num">£18,040</td><td class="num">£12,660</td><td class="num">£23,270</td><td class="num">£175,060</td></tr>
      </tbody>
    </table>
  </div>
</div>
CSS
/* Frozen first column and header: one scroll container with both overflow axes, a sticky <thead>
   row and a sticky first column of row headers. The pinned column casts a shadow on its right edge. */
.tcard{background:#fff;border:1px solid #e7e9f0;border-radius:14px;overflow:hidden}
.tscroll{overflow:auto;max-height:340px}
.tscroll:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.tbl{width:100%;min-width:900px;border-collapse:collapse;font-size:.89rem}
.tbl thead th{position:sticky;top:0;z-index:2;text-align:left;font-size:.74rem;letter-spacing:.06em;
  text-transform:uppercase;color:#5f6778;font-weight:700;padding:13px 16px;background:#fafbfc;
  border-bottom:1px solid #eceef3}
.tbl td,.tbl tbody th{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048;text-align:left}
.tbl tbody tr:last-child > *{border-bottom:0}
.tbl tbody tr:hover{background:#fafbfd}
.tbl .num{font-variant-numeric:tabular-nums;text-align:right}
.tbl thead th.num{text-align:right}
.tbl .pin{position:sticky;left:0;background:#fff;z-index:1;box-shadow:6px 0 8px -6px rgba(16,24,40,.16)}
.tbl thead th.pin{z-index:3;background:#fafbfc}
.tbl tbody tr:hover .pin{background:#fafbfd}
.tbl tbody th.pin{font-weight:600;color:#16181c}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="card tcard overflow-hidden">
  <div class="table-responsive" tabindex="0" role="region" aria-label="Regional revenue, scrollable">
    <table class="table table-hover align-middle mb-0 tbl">
      <caption class="visually-hidden">Regional revenue by month, in pounds</caption>
      <thead><tr>
        <th scope="col" class="pin">Month</th><th scope="col" class="text-end">UK</th><th scope="col" class="text-end">Germany</th><th scope="col" class="text-end">France</th><th scope="col" class="text-end">Spain</th><th scope="col" class="text-end">Poland</th><th scope="col" class="text-end">Nordics</th><th scope="col" class="text-end">Total</th>
      </tr></thead>
      <tbody>
        <tr><th scope="row" class="pin">January</th><td class="text-end num">£48,200</td><td class="text-end num">£31,940</td><td class="text-end num">£22,180</td><td class="text-end num">£14,760</td><td class="text-end num">£9,420</td><td class="text-end num">£18,330</td><td class="text-end num">£144,830</td></tr>
        <tr><th scope="row" class="pin">February</th><td class="text-end num">£51,470</td><td class="text-end num">£33,110</td><td class="text-end num">£20,940</td><td class="text-end num">£15,880</td><td class="text-end num">£10,150</td><td class="text-end num">£19,020</td><td class="text-end num">£150,570</td></tr>
        <tr><th scope="row" class="pin">March</th><td class="text-end num">£56,930</td><td class="text-end num">£35,780</td><td class="text-end num">£24,610</td><td class="text-end num">£17,240</td><td class="text-end num">£11,890</td><td class="text-end num">£21,460</td><td class="text-end num">£167,910</td></tr>
        <tr><th scope="row" class="pin">April</th><td class="text-end num">£54,180</td><td class="text-end num">£34,220</td><td class="text-end num">£23,050</td><td class="text-end num">£16,410</td><td class="text-end num">£10,930</td><td class="text-end num">£20,180</td><td class="text-end num">£158,970</td></tr>
        <tr><th scope="row" class="pin">May</th><td class="text-end num">£59,640</td><td class="text-end num">£37,910</td><td class="text-end num">£26,340</td><td class="text-end num">£18,720</td><td class="text-end num">£12,470</td><td class="text-end num">£22,890</td><td class="text-end num">£177,970</td></tr>
        <tr><th scope="row" class="pin">June</th><td class="text-end num">£62,310</td><td class="text-end num">£39,440</td><td class="text-end num">£27,880</td><td class="text-end num">£19,560</td><td class="text-end num">£13,220</td><td class="text-end num">£24,110</td><td class="text-end num">£186,520</td></tr>
        <tr><th scope="row" class="pin">July</th><td class="text-end num">£58,770</td><td class="text-end num">£36,890</td><td class="text-end num">£25,430</td><td class="text-end num">£18,040</td><td class="text-end num">£12,660</td><td class="text-end num">£23,270</td><td class="text-end num">£175,060</td></tr>
      </tbody>
    </table>
  </div>
</div>
CSS
/* Bootstrap .card + .table-responsive + .table-hover. The responsive wrapper gets a max-height so it
   scrolls both ways; the header row and the first column are sticky. The pinned cells keep
   Bootstrap's inset state shadow (it paints the hover colour) and add the edge shadow after it. */
.tcard{--bs-card-border-color:#e7e9f0;--bs-card-border-radius:14px;--bs-card-bg:#fff}
.tcard .table-responsive{overflow:auto;max-height:340px}
.tcard .table-responsive:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.tbl{--bs-table-color:#3c4048;--bs-table-bg:#fff;--bs-table-border-color:#f2f4f7;
  --bs-table-hover-color:#3c4048;--bs-table-hover-bg:#fafbfd;min-width:900px;font-size:.89rem}
.tbl > :not(caption) > * > *{padding:13px 16px}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{position:sticky;top:0;z-index:2;font-size:.74rem;letter-spacing:.06em;
  text-transform:uppercase;color:#5f6778;--bs-table-bg:#fafbfc;border-bottom-color:#eceef3}
.tbl .num{font-variant-numeric:tabular-nums}
.tbl .pin{position:sticky;left:0;z-index:1;
  box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg))),6px 0 8px -6px rgba(16,24,40,.16)}
.tbl > thead > tr > th.pin{z-index:3}
.tbl > tbody > tr > th.pin{font-weight:600;--bs-table-color:#16181c}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <div class="max-h-[340px] overflow-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" tabindex="0" role="region" aria-label="Regional revenue, scrollable">
    <table class="w-full min-w-[900px] border-collapse text-[.89rem] [&_tbody_td]:border-b [&_tbody_td]:border-[#f2f4f7] [&_tbody_td]:px-4 [&_tbody_td]:py-[13px] [&_tbody_td]:text-right [&_tbody_td]:text-[#3c4048] [&_tbody_td]:tabular-nums [&_tbody_th]:border-b [&_tbody_th]:border-[#f2f4f7] [&_tbody_th]:px-4 [&_tbody_th]:py-[13px] [&_tbody_tr:last-child>*]:border-b-0">
      <caption class="sr-only">Regional revenue by month, in pounds</caption>
      <thead><tr>
        <th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase left-0 z-3 text-left shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)]">Month</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">UK</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Germany</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">France</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Spain</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Poland</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Nordics</th><th scope="col" class="sticky top-0 z-2 border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Total</th>
      </tr></thead>
      <tbody>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">January</th><td>£48,200</td><td>£31,940</td><td>£22,180</td><td>£14,760</td><td>£9,420</td><td>£18,330</td><td>£144,830</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">February</th><td>£51,470</td><td>£33,110</td><td>£20,940</td><td>£15,880</td><td>£10,150</td><td>£19,020</td><td>£150,570</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">March</th><td>£56,930</td><td>£35,780</td><td>£24,610</td><td>£17,240</td><td>£11,890</td><td>£21,460</td><td>£167,910</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">April</th><td>£54,180</td><td>£34,220</td><td>£23,050</td><td>£16,410</td><td>£10,930</td><td>£20,180</td><td>£158,970</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">May</th><td>£59,640</td><td>£37,910</td><td>£26,340</td><td>£18,720</td><td>£12,470</td><td>£22,890</td><td>£177,970</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">June</th><td>£62,310</td><td>£39,440</td><td>£27,880</td><td>£19,560</td><td>£13,220</td><td>£24,110</td><td>£186,520</td></tr>
        <tr class="group hover:bg-[#fafbfd]"><th scope="row" class="sticky left-0 z-1 bg-white text-left font-semibold text-[#16181c] shadow-[6px_0_8px_-6px_rgba(16,24,40,.16)] group-hover:bg-[#fafbfd]">July</th><td>£58,770</td><td>£36,890</td><td>£25,430</td><td>£18,040</td><td>£12,660</td><td>£23,270</td><td>£175,060</td></tr>
      </tbody>
    </table>
  </div>
</div>

About this set

A wide revenue report, months down the side and six regions plus a total across the top, that scrolls in both directions inside a fixed-height card while the header row and the month column stay in place. The scroll container has overflow auto and a maximum height of 340 pixels, the header cells use position sticky with top 0, and the month cells use position sticky with left 0. The corner cell sits above both with a higher z-index, and the frozen column casts a soft shadow on its right edge so it reads as a layer rather than a rendering fault. Months are row headers and the regions are column headers, so screen readers announce both for every figure. The container is a labelled region with tabindex 0, which lets keyboard users scroll it with the arrow keys. Use it for financial reports, schedules and comparison grids that are wider than the screen. In Bootstrap the frozen cells keep the inset shadow Bootstrap uses for row hover and add the edge shadow after it, so hover still works on the pinned column.

What’s included

  • Sticky header row and sticky first column
  • Edge shadow on the frozen column
  • Scrolls both ways inside a 340px high card
  • Month cells are row headers, regions are column headers
  • Keyboard-scrollable labelled region

Accessibility

The table has a caption, column headers with scope="col" and month row headers with scope="row", so each figure is announced with its month and region. The scroll container is a role="region" with an aria-label and tabindex="0", and it shows a 2px indigo outline on keyboard focus. Nothing animates.

Customise it

Set the visible height with max-height on the scroll container and the table width with min-width. The edge shadow is the 6px 0 8px -6px box-shadow on .pin (plain CSS and Bootstrap) or the shadow- utility on the month cells (Tailwind).