Bootstrap Tables: 16 Examples, From Striped to Sortable

A plain table on a Bootstrap page looks unstyled until .table goes on it. Then classes add stripes, borders, hover states, colors and horizontal scrolling, with no JavaScript. Bootstrap runs on 74.8% of sites whose CSS framework W3Techs can identify (October 2026).

Below are 16 Bootstrap table examples you can copy, followed by a guide to the classes, the dropdown fix and the v4 to v5 changes.

By Bogdan Sandu ยท Updated October 29, 2026

Every example runs on Bootstrap 5.3.8 from the jsDelivr CDN. Add these tags to your page, then paste an example's HTML, CSS and JS.

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.2/font/bootstrap-icons.min.css" rel="stylesheet" integrity="sha384-nWN3KqaaVcbEsCjLSV+2jMjh62Iny/+mN3srM8TZeM8N6aj7eDl5wwYggjAJ4/sJ" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>

Table structure and style classes

Invoices, order lists, metrics and status boards built from .table and its modifiers: captions, tfoot totals, group dividers, stripes, hover, borders and contextual row colors.

Invoice with tfoot totals

  • caption-top
  • table-group-divider
  • tfoot
  • scope
  • text-end

The line items sit in a .table with a top caption, column headers with scope="col" and a tfoot whose row headers use scope="row" and colspan. .table-group-divider on the tbody and tfoot draws the thicker rule between groups, and text-end right-aligns every amount. The custom CSS only sizes the card and switches the numbers to tabular figures so the columns line up.

View code
<div class="card btb-invoice border-0 shadow-sm">
  <div class="card-body p-4">
    <div class="d-flex flex-wrap justify-content-between gap-3 mb-3">
      <div>
        <h2 class="h5 mb-1">Fernhill Studio</h2>
        <p class="small text-body-secondary mb-0">Invoice INV-2026-0418, issued 14 Oct 2026</p>
      </div>
      <div class="text-sm-end">
        <p class="small text-body-secondary mb-0">Billed to</p>
        <p class="fw-semibold mb-0">Larkspur Coffee Roasters</p>
      </div>
    </div>
    <table class="table caption-top align-middle mb-0">
      <caption>Line items, prices in USD</caption>
      <thead>
        <tr>
          <th scope="col">Item</th>
          <th scope="col" class="text-end">Qty</th>
          <th scope="col" class="text-end d-none d-sm-table-cell">Rate</th>
          <th scope="col" class="text-end">Amount</th>
        </tr>
      </thead>
      <tbody class="table-group-divider">
        <tr>
          <td><span class="d-block fw-medium">Brand workshop</span><span class="small text-body-secondary">Two half-day sessions</span></td>
          <td class="text-end">2</td>
          <td class="text-end d-none d-sm-table-cell">$650.00</td>
          <td class="text-end">$1,300.00</td>
        </tr>
        <tr>
          <td><span class="d-block fw-medium">Packaging design</span><span class="small text-body-secondary">250 g and 1 kg bags</span></td>
          <td class="text-end">2</td>
          <td class="text-end d-none d-sm-table-cell">$980.00</td>
          <td class="text-end">$1,960.00</td>
        </tr>
        <tr>
          <td><span class="d-block fw-medium">Menu board layout</span><span class="small text-body-secondary">Print-ready PDF</span></td>
          <td class="text-end">1</td>
          <td class="text-end d-none d-sm-table-cell">$540.00</td>
          <td class="text-end">$540.00</td>
        </tr>
        <tr>
          <td><span class="d-block fw-medium">Revisions</span><span class="small text-body-secondary">Hourly</span></td>
          <td class="text-end">4</td>
          <td class="text-end d-none d-sm-table-cell">$95.00</td>
          <td class="text-end">$380.00</td>
        </tr>
      </tbody>
      <tfoot class="table-group-divider">
        <tr>
          <th scope="row" colspan="2" class="text-end fw-normal d-sm-none">Subtotal</th>
          <th scope="row" colspan="3" class="text-end fw-normal d-none d-sm-table-cell">Subtotal</th>
          <td class="text-end">$4,180.00</td>
        </tr>
        <tr>
          <th scope="row" colspan="2" class="text-end fw-normal d-sm-none">Sales tax 8%</th>
          <th scope="row" colspan="3" class="text-end fw-normal d-none d-sm-table-cell">Sales tax 8%</th>
          <td class="text-end">$334.40</td>
        </tr>
        <tr class="table-light">
          <th scope="row" colspan="2" class="text-end d-sm-none">Total due</th>
          <th scope="row" colspan="3" class="text-end d-none d-sm-table-cell">Total due</th>
          <td class="text-end fw-bold">$4,514.40</td>
        </tr>
      </tfoot>
    </table>
  </div>
</div>

Striped order list with status badges

  • table-striped
  • table-hover
  • badge
  • align-middle
  • d-none d-md-table-cell

Stripes and hover stack on the same table, and .align-middle centers the avatar rows vertically. Each status is a text badge (text-bg-success, text-bg-warning and so on), so the meaning is in the words and not only the color. The date and email columns use d-none d-md-table-cell so the list still fits a phone.

View code
<div class="card btb-orders border-0 shadow-sm">
  <div class="card-header bg-body d-flex justify-content-between align-items-center py-3">
    <h2 class="h6 mb-0">Recent orders</h2>
    <a href="#" class="small">View all</a>
  </div>
  <div class="table-responsive">
  <table class="table table-striped table-hover align-middle mb-0">
    <thead>
      <tr>
        <th scope="col">Order</th>
        <th scope="col">Customer</th>
        <th scope="col" class="d-none d-md-table-cell">Placed</th>
        <th scope="col">Status</th>
        <th scope="col" class="text-end">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td class="fw-medium">#40291</td>
        <td><div class="d-flex align-items-center gap-2"><span class="d-none d-sm-inline-grid btb-ava" aria-hidden="true">MO</span><span><span class="d-block">Maya Okafor</span><span class="small text-body-secondary d-none d-md-block">maya@okafor.example</span></span></div></td>
        <td class="d-none d-md-table-cell">Oct 27, 2026</td>
        <td><span class="badge rounded-pill text-bg-success">Delivered</span></td>
        <td class="text-end">$128.40</td>
      </tr>
      <tr>
        <td class="fw-medium">#40288</td>
        <td><div class="d-flex align-items-center gap-2"><span class="d-none d-sm-inline-grid btb-ava btb-ava-2" aria-hidden="true">JB</span><span><span class="d-block">Jonas Berg</span><span class="small text-body-secondary d-none d-md-block">jonas.berg@mail.example</span></span></div></td>
        <td class="d-none d-md-table-cell">Oct 27, 2026</td>
        <td><span class="badge rounded-pill text-bg-info">Shipped</span></td>
        <td class="text-end">$64.00</td>
      </tr>
      <tr>
        <td class="fw-medium">#40285</td>
        <td><div class="d-flex align-items-center gap-2"><span class="d-none d-sm-inline-grid btb-ava btb-ava-3" aria-hidden="true">AF</span><span><span class="d-block">Ana Ferreira</span><span class="small text-body-secondary d-none d-md-block">ana.f@studio.example</span></span></div></td>
        <td class="d-none d-md-table-cell">Oct 26, 2026</td>
        <td><span class="badge rounded-pill text-bg-warning">Processing</span></td>
        <td class="text-end">$312.95</td>
      </tr>
      <tr>
        <td class="fw-medium">#40279</td>
        <td><div class="d-flex align-items-center gap-2"><span class="d-none d-sm-inline-grid btb-ava btb-ava-4" aria-hidden="true">LK</span><span><span class="d-block">Lena Kowalski</span><span class="small text-body-secondary d-none d-md-block">lena@kowalski.example</span></span></div></td>
        <td class="d-none d-md-table-cell">Oct 25, 2026</td>
        <td><span class="badge rounded-pill text-bg-secondary">Refunded</span></td>
        <td class="text-end">$45.00</td>
      </tr>
      <tr>
        <td class="fw-medium">#40274</td>
        <td><div class="d-flex align-items-center gap-2"><span class="d-none d-sm-inline-grid btb-ava btb-ava-5" aria-hidden="true">RT</span><span><span class="d-block">Ravi Thakur</span><span class="small text-body-secondary d-none d-md-block">ravi.t@north.example</span></span></div></td>
        <td class="d-none d-md-table-cell">Oct 24, 2026</td>
        <td><span class="badge rounded-pill text-bg-danger">Cancelled</span></td>
        <td class="text-end">$89.90</td>
      </tr>
    </tbody>
  </table>
  </div>
</div>

Compact metrics grid with striped columns

  • table-sm
  • table-striped-columns
  • table-responsive
  • th scope="row"
  • text-nowrap

A dense monthly report is where .table-sm earns its place: it halves the cell padding to .25rem. .table-striped-columns (added in 5.2.0) shades every even column so the eye can follow one month down the grid, and each metric name is a th with scope="row". The wrapper scrolls on phones, and the only custom CSS is tabular figures and a muted trend color.

View code
<div class="card btb-metrics border-0 shadow-sm">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-1">Trailhead Journal, site metrics</h2>
    <p class="small text-body-secondary mb-3">First half of 2026, monthly totals</p>
    <div class="table-responsive">
      <table class="table table-sm table-striped-columns text-nowrap mb-0">
        <thead>
          <tr>
            <th scope="col">Metric</th>
            <th scope="col" class="text-end">Jan</th>
            <th scope="col" class="text-end">Feb</th>
            <th scope="col" class="text-end">Mar</th>
            <th scope="col" class="text-end">Apr</th>
            <th scope="col" class="text-end">May</th>
            <th scope="col" class="text-end">Jun</th>
          </tr>
        </thead>
        <tbody>
          <tr><th scope="row">Visits</th><td class="text-end">41,208</td><td class="text-end">39,870</td><td class="text-end">46,115</td><td class="text-end">52,430</td><td class="text-end">58,902</td><td class="text-end">61,377</td></tr>
          <tr><th scope="row">New subscribers</th><td class="text-end">312</td><td class="text-end">298</td><td class="text-end">355</td><td class="text-end">417</td><td class="text-end">486</td><td class="text-end">503</td></tr>
          <tr><th scope="row">Avg. read time</th><td class="text-end">3:41</td><td class="text-end">3:38</td><td class="text-end">3:52</td><td class="text-end">4:05</td><td class="text-end">4:11</td><td class="text-end">4:09</td></tr>
          <tr><th scope="row">Bounce rate</th><td class="text-end">48.2%</td><td class="text-end">49.0%</td><td class="text-end">46.7%</td><td class="text-end">44.1%</td><td class="text-end">42.8%</td><td class="text-end">42.5%</td></tr>
          <tr><th scope="row">Newsletter CTR</th><td class="text-end">5.1%</td><td class="text-end">4.8%</td><td class="text-end">5.4%</td><td class="text-end">5.9%</td><td class="text-end">6.3%</td><td class="text-end">6.2%</td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Service status board with row variants

  • table-bordered
  • table-success
  • table-warning
  • table-danger
  • thead table-light

Contextual classes go on single rows here: table-success, table-warning and table-danger color a whole tr, while table-light on the thead gives the header its own shade. Every colored row also says its state in words, because color alone does not reach screen reader users. The bordered table adds vertical rules that help on a grid people scan across.

View code
<div class="btb-status">
  <div class="d-flex flex-wrap align-items-baseline justify-content-between gap-2 mb-3">
    <h2 class="h5 mb-0">Relaywire system status</h2>
    <p class="small text-body-secondary mb-0">Updated 09:42 UTC</p>
  </div>
  <table class="table table-bordered align-middle mb-0">
    <thead class="table-light">
      <tr>
        <th scope="col">Service</th>
        <th scope="col">Status</th>
        <th scope="col" class="text-end d-none d-sm-table-cell">Uptime, 90 days</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row" class="fw-medium">REST API</th>
        <td><i class="bi bi-check-circle-fill text-success me-1" aria-hidden="true"></i>Operational</td>
        <td class="text-end d-none d-sm-table-cell">99.99%</td>
      </tr>
      <tr>
        <th scope="row" class="fw-medium">Dashboard</th>
        <td><i class="bi bi-check-circle-fill text-success me-1" aria-hidden="true"></i>Operational</td>
        <td class="text-end d-none d-sm-table-cell">99.97%</td>
      </tr>
      <tr class="table-warning">
        <th scope="row" class="fw-medium">Webhooks</th>
        <td><i class="bi bi-exclamation-triangle-fill me-1" aria-hidden="true"></i>Degraded, delays up to 4 min</td>
        <td class="text-end d-none d-sm-table-cell">99.71%</td>
      </tr>
      <tr class="table-danger">
        <th scope="row" class="fw-medium">EU file storage</th>
        <td><i class="bi bi-x-octagon-fill me-1" aria-hidden="true"></i>Outage, team investigating</td>
        <td class="text-end d-none d-sm-table-cell">98.86%</td>
      </tr>
      <tr class="table-success">
        <th scope="row" class="fw-medium">Email delivery</th>
        <td><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Recovered at 08:15</td>
        <td class="text-end d-none d-sm-table-cell">99.93%</td>
      </tr>
    </tbody>
  </table>
</div>

Responsive and mobile table layouts

Four ways to keep a table usable on a phone: a scrolling wrapper, rows that stack into cards, a sticky header inside a scroll box and columns that hide below a breakpoint.

Wide departures board that scrolls

  • table-responsive
  • text-nowrap
  • role="region"
  • tabindex="0"
  • caption

Nine columns cannot fit a phone, so the table sits in a .table-responsive wrapper and scrolls sideways at every width; text-nowrap keeps times and gate codes on one line. The wrapper gets tabindex="0", role="region" and aria-labelledby pointing at the caption, so keyboard users can focus it and scroll with the arrow keys. The custom CSS adds a visible focus ring and a thin scrollbar.

View code
<div class="card btb-dep border-0 shadow-sm">
  <div class="card-body p-3 p-sm-4">
    <div class="table-responsive btb-dep-scroll" tabindex="0" role="region" aria-labelledby="btb-dep-cap">
      <table class="table table-hover text-nowrap align-middle mb-0">
        <caption id="btb-dep-cap" class="caption-top pt-0">Departures, Terminal 2, Wednesday 28 October</caption>
        <thead>
          <tr>
            <th scope="col">Flight</th>
            <th scope="col">Destination</th>
            <th scope="col">Airline</th>
            <th scope="col">Scheduled</th>
            <th scope="col">Estimated</th>
            <th scope="col">Check-in</th>
            <th scope="col">Gate</th>
            <th scope="col">Aircraft</th>
            <th scope="col">Status</th>
          </tr>
        </thead>
        <tbody>
          <tr><td class="fw-semibold">NR 412</td><td>Reykjavik</td><td>Northreach Air</td><td>10:05</td><td>10:05</td><td>D 11-14</td><td>B22</td><td>A321neo</td><td><span class="badge text-bg-success">Boarding</span></td></tr>
          <tr><td class="fw-semibold">CS 2081</td><td>Lisbon</td><td>Coastline</td><td>10:20</td><td>10:45</td><td>F 01-06</td><td>A07</td><td>737-800</td><td><span class="badge text-bg-warning">Delayed</span></td></tr>
          <tr><td class="fw-semibold">NR 908</td><td>Oslo</td><td>Northreach Air</td><td>10:35</td><td>10:35</td><td>D 11-14</td><td>B19</td><td>A320</td><td><span class="badge text-bg-primary">Gate open</span></td></tr>
          <tr><td class="fw-semibold">PL 330</td><td>Krakow</td><td>Polaris Lines</td><td>11:10</td><td>11:10</td><td>E 03-05</td><td>C02</td><td>E195-E2</td><td><span class="badge text-bg-secondary">Check-in</span></td></tr>
          <tr><td class="fw-semibold">CS 2215</td><td>Porto</td><td>Coastline</td><td>11:25</td><td>-</td><td>F 01-06</td><td>-</td><td>737-800</td><td><span class="badge text-bg-danger">Cancelled</span></td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Rows that stack into cards on phones

  • table
  • data-label
  • attr()
  • @media
  • ARIA table roles

Above 576px this is a plain .table. Below it, the custom CSS turns every row into a bordered card and prints each column name from a data-label attribute with ::before content: attr(data-label), while the thead is hidden visually but kept for screen readers. Because changing the display of table elements can strip their table semantics in some browsers, the markup restores them with role="table", "row", "columnheader" and "cell".

View code
<div class="btb-stack">
  <h2 class="h5 mb-3">Sprint 42 tasks</h2>
  <table class="table align-middle mb-0" role="table">
    <thead role="rowgroup">
      <tr role="row">
        <th scope="col" role="columnheader">Task</th>
        <th scope="col" role="columnheader">Owner</th>
        <th scope="col" role="columnheader">Due</th>
        <th scope="col" role="columnheader">Estimate</th>
        <th scope="col" role="columnheader">Status</th>
      </tr>
    </thead>
    <tbody role="rowgroup">
      <tr role="row">
        <td role="cell" data-label="Task" class="fw-medium">Checkout address autocomplete</td>
        <td role="cell" data-label="Owner">Priya N.</td>
        <td role="cell" data-label="Due">Oct 30</td>
        <td role="cell" data-label="Estimate">5 pts</td>
        <td role="cell" data-label="Status"><span class="badge text-bg-primary">In progress</span></td>
      </tr>
      <tr role="row">
        <td role="cell" data-label="Task" class="fw-medium">Refund email template</td>
        <td role="cell" data-label="Owner">Tomas R.</td>
        <td role="cell" data-label="Due">Oct 31</td>
        <td role="cell" data-label="Estimate">2 pts</td>
        <td role="cell" data-label="Status"><span class="badge text-bg-light border">In review</span></td>
      </tr>
      <tr role="row">
        <td role="cell" data-label="Task" class="fw-medium">Fix stale cart totals</td>
        <td role="cell" data-label="Owner">Mina K.</td>
        <td role="cell" data-label="Due">Nov 3</td>
        <td role="cell" data-label="Estimate">3 pts</td>
        <td role="cell" data-label="Status"><span class="badge text-bg-success">Done</span></td>
      </tr>
    </tbody>
  </table>
</div>

Sticky header inside a scroll box

  • position: sticky
  • overflow-y-auto
  • thead table-light
  • table-sm
  • role="region"

Scroll inside the table.

Bootstrap has no sticky table header class, so two custom rules do it: the wrapper gets a max-height with vertical scrolling and the th cells get position: sticky; top: 0. The thead uses .table-light, which sets an opaque --bs-table-bg so rows do not show through, and an inset shadow replaces the bottom border that border-collapse drops while the header is stuck.

View code
<div class="card btb-stock border-0 shadow-sm">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3" id="btb-stock-title">Stock levels, Leeds warehouse</h2>
    <div class="btb-stock-box" tabindex="0" role="region" aria-labelledby="btb-stock-title">
      <table class="table table-sm table-hover mb-0">
        <thead class="table-light">
          <tr>
            <th scope="col">SKU</th>
            <th scope="col">Product</th>
            <th scope="col" class="text-end">On hand</th>
          </tr>
        </thead>
        <tbody>
          <tr><td>MG-1040</td><td>Stoneware mug, sage</td><td class="text-end">214</td></tr>
          <tr><td>MG-1041</td><td>Stoneware mug, sand</td><td class="text-end">187</td></tr>
          <tr><td>MG-1042</td><td>Stoneware mug, ink</td><td class="text-end">96</td></tr>
          <tr><td>PL-2210</td><td>Dinner plate, 27 cm</td><td class="text-end">340</td></tr>
          <tr><td>PL-2211</td><td>Side plate, 20 cm</td><td class="text-end">402</td></tr>
          <tr class="table-warning"><td>BW-3105</td><td>Pasta bowl, low stock</td><td class="text-end">12</td></tr>
          <tr><td>BW-3106</td><td>Cereal bowl</td><td class="text-end">158</td></tr>
          <tr><td>JG-4002</td><td>Milk jug, 300 ml</td><td class="text-end">77</td></tr>
          <tr><td>TP-5120</td><td>Teapot, 1 l</td><td class="text-end">41</td></tr>
          <tr><td>TP-5121</td><td>Teapot lid, spare</td><td class="text-end">63</td></tr>
          <tr class="table-danger"><td>CN-6001</td><td>Candle holder, out of stock</td><td class="text-end">0</td></tr>
          <tr><td>VS-7008</td><td>Bud vase, tall</td><td class="text-end">129</td></tr>
          <tr><td>VS-7009</td><td>Bud vase, short</td><td class="text-end">144</td></tr>
          <tr><td>SV-8030</td><td>Serving platter</td><td class="text-end">58</td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Columns that hide below md

  • d-none d-md-table-cell
  • d-none d-lg-table-cell
  • text-truncate
  • badge
  • table-hover

Less important columns carry d-none plus a responsive table-cell display, d-md-table-cell or d-lg-table-cell, on every th and td in that column. table-cell keeps the cell a real table cell when it comes back; d-md-block would break the row. The frame on this page is under 992px wide, so the Updated column stays hidden even on desktop and Agent appears from 768px up.

View code
<div class="card btb-tickets border-0 shadow-sm">
  <div class="card-header bg-body py-3">
    <h2 class="h6 mb-0">Open support tickets</h2>
  </div>
  <table class="table table-hover align-middle mb-0">
    <thead>
      <tr>
        <th scope="col">Subject</th>
        <th scope="col">Priority</th>
        <th scope="col" class="d-none d-md-table-cell">Agent</th>
        <th scope="col" class="d-none d-lg-table-cell">Updated</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><a href="#" class="d-block fw-medium text-decoration-none">Invoice PDF shows the wrong currency</a><span class="small text-body-secondary">#8821, Hollis Bakery</span></td>
        <td><span class="badge text-bg-danger">Urgent</span></td>
        <td class="d-none d-md-table-cell">Sam Ortega</td>
        <td class="d-none d-lg-table-cell">4 min ago</td>
      </tr>
      <tr>
        <td><a href="#" class="d-block fw-medium text-decoration-none">Cannot invite a second admin</a><span class="small text-body-secondary">#8817, Nordvik Cycles</span></td>
        <td><span class="badge text-bg-warning">High</span></td>
        <td class="d-none d-md-table-cell">Ines Duarte</td>
        <td class="d-none d-lg-table-cell">22 min ago</td>
      </tr>
      <tr>
        <td><a href="#" class="d-block fw-medium text-decoration-none">CSV export misses the notes field</a><span class="small text-body-secondary">#8809, Pine and Thread</span></td>
        <td><span class="badge text-bg-light border">Normal</span></td>
        <td class="d-none d-md-table-cell">Sam Ortega</td>
        <td class="d-none d-lg-table-cell">1 h ago</td>
      </tr>
      <tr>
        <td><a href="#" class="d-block fw-medium text-decoration-none">Question about annual billing</a><span class="small text-body-secondary">#8802, Arlo Dental</span></td>
        <td><span class="badge text-bg-light border">Low</span></td>
        <td class="d-none d-md-table-cell">Unassigned</td>
        <td class="d-none d-lg-table-cell">3 h ago</td>
      </tr>
    </tbody>
  </table>
</div>

Sorting, filtering, selection and paging

Bootstrap tables are CSS only, so these demos add a few lines of vanilla JavaScript for sorting, row selection, filtering, paging and row menus. No plugin, no jQuery.

Sortable columns with aria-sort

  • aria-sort
  • button in th
  • data-value
  • appendChild
  • Bootstrap Icons

Click a column header to sort, click again to reverse.

Each sortable header holds a real button, so it is focusable and works with Enter and Space. On click the script sorts the tbody rows by their data-value (numbers) or text, moves them with appendChild, sets aria-sort to ascending or descending on that th only and announces the new order in a visually-hidden live region. Nothing is rebuilt with innerHTML; the rows keep their own nodes.

View code
<div class="card btb-sort border-0 shadow-sm">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3">Day hikes near Glen Coe</h2>
    <div class="table-responsive">
      <table class="table table-hover align-middle mb-0" id="btb-sort-table">
        <thead>
          <tr>
            <th scope="col" aria-sort="ascending"><button type="button" class="btb-sort-btn" data-btb-type="text"><span>Trail</span><i class="bi bi-caret-up-fill" aria-hidden="true"></i></button></th>
            <th scope="col" class="text-end"><button type="button" class="btb-sort-btn" data-btb-type="number"><span>Distance</span><i class="bi bi-chevron-expand" aria-hidden="true"></i></button></th>
            <th scope="col" class="text-end d-none d-sm-table-cell"><button type="button" class="btb-sort-btn" data-btb-type="number"><span>Ascent</span><i class="bi bi-chevron-expand" aria-hidden="true"></i></button></th>
            <th scope="col" class="text-end"><button type="button" class="btb-sort-btn" data-btb-type="number"><span>Rating</span><i class="bi bi-chevron-expand" aria-hidden="true"></i></button></th>
          </tr>
        </thead>
        <tbody>
          <tr><td>Allt Coire Gabhail</td><td class="text-end" data-value="4.2">4.2 km</td><td class="text-end d-none d-sm-table-cell" data-value="350">350 m</td><td class="text-end" data-value="4.8">4.8</td></tr>
          <tr><td>Buachaille Etive Beag</td><td class="text-end" data-value="8.1">8.1 km</td><td class="text-end d-none d-sm-table-cell" data-value="880">880 m</td><td class="text-end" data-value="4.7">4.7</td></tr>
          <tr><td>Glencoe Lochan</td><td class="text-end" data-value="2.4">2.4 km</td><td class="text-end d-none d-sm-table-cell" data-value="60">60 m</td><td class="text-end" data-value="4.3">4.3</td></tr>
          <tr><td>Pap of Glencoe</td><td class="text-end" data-value="7.5">7.5 km</td><td class="text-end d-none d-sm-table-cell" data-value="740">740 m</td><td class="text-end" data-value="4.6">4.6</td></tr>
          <tr><td>Signal Rock</td><td class="text-end" data-value="3.1">3.1 km</td><td class="text-end d-none d-sm-table-cell" data-value="90">90 m</td><td class="text-end" data-value="4.1">4.1</td></tr>
        </tbody>
      </table>
    </div>
    <p class="visually-hidden" role="status" id="btb-sort-status"></p>
  </div>
</div>

Selectable rows with a bulk-action bar

  • form-check-input
  • indeterminate
  • table-active
  • btn-group
  • aria-live

Tick a few rows, then try Delete.

Every row has a form-check-input checkbox with an aria-label naming the file, and the header checkbox selects all of them, showing the indeterminate state when only some are checked. Checked rows get .table-active, which sets --bs-table-bg-state so the highlight wins over hover. The bar above the table reports the count in a polite live region and enables its buttons only when something is selected.

View code
<div class="card btb-files border-0 shadow-sm" id="btb-files">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3">Brand assets</h2>
    <div class="btb-files-bar d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
      <p class="small fw-medium mb-0" id="btb-files-count" aria-live="polite">No files selected</p>
      <div class="btn-group btn-group-sm" role="group" aria-label="Bulk actions">
        <button type="button" class="btn btn-outline-secondary btb-files-act" disabled><i class="bi bi-download me-1" aria-hidden="true"></i>Download</button>
        <button type="button" class="btn btn-outline-danger btb-files-act" id="btb-files-delete" disabled><i class="bi bi-trash3 me-1" aria-hidden="true"></i>Delete</button>
      </div>
    </div>
    <div class="table-responsive">
      <table class="table table-hover align-middle mb-0">
        <thead>
          <tr>
            <th scope="col" class="btb-files-check"><input class="form-check-input" type="checkbox" id="btb-files-all" aria-label="Select all files"></th>
            <th scope="col">Name</th>
            <th scope="col" class="d-none d-sm-table-cell">Type</th>
            <th scope="col" class="text-end">Size</th>
          </tr>
        </thead>
        <tbody>
          <tr><td class="btb-files-check"><input class="form-check-input btb-file-cb" type="checkbox" aria-label="Select logo-primary.svg"></td><td class="text-break">logo-primary.svg</td><td class="d-none d-sm-table-cell">SVG</td><td class="text-end text-nowrap">18 KB</td></tr>
          <tr><td class="btb-files-check"><input class="form-check-input btb-file-cb" type="checkbox" aria-label="Select brand-guidelines.pdf"></td><td class="text-break">brand-guidelines.pdf</td><td class="d-none d-sm-table-cell">PDF</td><td class="text-end text-nowrap">4.2 MB</td></tr>
          <tr><td class="btb-files-check"><input class="form-check-input btb-file-cb" type="checkbox" aria-label="Select hero-autumn.jpg"></td><td class="text-break">hero-autumn.jpg</td><td class="d-none d-sm-table-cell">JPEG</td><td class="text-end text-nowrap">1.1 MB</td></tr>
          <tr><td class="btb-files-check"><input class="form-check-input btb-file-cb" type="checkbox" aria-label="Select social-kit.zip"></td><td class="text-break">social-kit.zip</td><td class="d-none d-sm-table-cell">ZIP</td><td class="text-end text-nowrap">23 MB</td></tr>
          <tr><td class="btb-files-check"><input class="form-check-input btb-file-cb" type="checkbox" aria-label="Select color-tokens.json"></td><td class="text-break">color-tokens.json</td><td class="d-none d-sm-table-cell">JSON</td><td class="text-end text-nowrap">3 KB</td></tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Row actions dropdown that escapes the scroll wrapper

  • dropdown
  • table-responsive
  • popperConfig
  • strategy: fixed
  • dropdown-menu-end

Open the menu on any row.

A .table-responsive wrapper clips anything that pokes past its edges, including dropdown menus opened from a cell. This demo keeps the wrapper and creates each Dropdown with popperConfig { strategy: "fixed" }, so Popper positions the menu relative to the viewport and the scroll box no longer cuts it off. The toggles are icon-only buttons, so each carries an aria-label with the project name.

View code
<div class="card btb-proj border-0 shadow-sm">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3">Active projects</h2>
    <div class="table-responsive">
      <table class="table align-middle mb-0">
        <thead>
          <tr>
            <th scope="col">Project</th>
            <th scope="col" class="d-none d-sm-table-cell">Client</th>
            <th scope="col">Due</th>
            <th scope="col"><span class="visually-hidden">Actions</span></th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td class="fw-medium">Spring lookbook</td>
            <td class="d-none d-sm-table-cell">Marlow and Finch</td>
            <td class="text-nowrap">Nov 12</td>
            <td class="text-end">
              <div class="dropdown">
                <button type="button" class="btn btn-sm btn-light btb-proj-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Actions for Spring lookbook"><i class="bi bi-three-dots" aria-hidden="true"></i></button>
                <ul class="dropdown-menu dropdown-menu-end shadow-sm">
                  <li><a class="dropdown-item" href="#"><i class="bi bi-pencil me-2" aria-hidden="true"></i>Edit</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-copy me-2" aria-hidden="true"></i>Duplicate</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-archive me-2" aria-hidden="true"></i>Archive</a></li>
                  <li><hr class="dropdown-divider"></li>
                  <li><a class="dropdown-item text-danger" href="#"><i class="bi bi-trash3 me-2" aria-hidden="true"></i>Delete</a></li>
                </ul>
              </div>
            </td>
          </tr>
          <tr>
            <td class="fw-medium">Onboarding emails</td>
            <td class="d-none d-sm-table-cell">Tallyfold</td>
            <td class="text-nowrap">Nov 20</td>
            <td class="text-end">
              <div class="dropdown">
                <button type="button" class="btn btn-sm btn-light btb-proj-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Actions for Onboarding emails"><i class="bi bi-three-dots" aria-hidden="true"></i></button>
                <ul class="dropdown-menu dropdown-menu-end shadow-sm">
                  <li><a class="dropdown-item" href="#"><i class="bi bi-pencil me-2" aria-hidden="true"></i>Edit</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-copy me-2" aria-hidden="true"></i>Duplicate</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-archive me-2" aria-hidden="true"></i>Archive</a></li>
                  <li><hr class="dropdown-divider"></li>
                  <li><a class="dropdown-item text-danger" href="#"><i class="bi bi-trash3 me-2" aria-hidden="true"></i>Delete</a></li>
                </ul>
              </div>
            </td>
          </tr>
          <tr>
            <td class="fw-medium">Store signage</td>
            <td class="d-none d-sm-table-cell">Hollis Bakery</td>
            <td class="text-nowrap">Dec 1</td>
            <td class="text-end">
              <div class="dropdown">
                <button type="button" class="btn btn-sm btn-light btb-proj-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Actions for Store signage"><i class="bi bi-three-dots" aria-hidden="true"></i></button>
                <ul class="dropdown-menu dropdown-menu-end shadow-sm">
                  <li><a class="dropdown-item" href="#"><i class="bi bi-pencil me-2" aria-hidden="true"></i>Edit</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-copy me-2" aria-hidden="true"></i>Duplicate</a></li>
                  <li><a class="dropdown-item" href="#"><i class="bi bi-archive me-2" aria-hidden="true"></i>Archive</a></li>
                  <li><hr class="dropdown-divider"></li>
                  <li><a class="dropdown-item text-danger" href="#"><i class="bi bi-trash3 me-2" aria-hidden="true"></i>Delete</a></li>
                </ul>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Search and filter that hides rows

  • input-group
  • form-select
  • hidden
  • role="status"
  • colspan empty row

Type a name or role, or pick a team.

A search field and a team select filter the rows by setting the hidden property, so nothing is removed from the DOM. A role="status" line reports how many people match and a colspan row appears when none do. The table is not striped on purpose: .table-striped counts rows with nth-of-type, so hidden rows would leave two shaded rows next to each other.

View code
<div class="card btb-dir border-0 shadow-sm" id="btb-dir">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3">Team directory</h2>
    <div class="row g-2 mb-2" role="search">
      <div class="col-sm-7">
        <label for="btb-dir-q" class="visually-hidden">Search people</label>
        <div class="input-group">
          <span class="input-group-text bg-body"><i class="bi bi-search" aria-hidden="true"></i></span>
          <input type="search" class="form-control" id="btb-dir-q" placeholder="Search name or role" autocomplete="off">
        </div>
      </div>
      <div class="col-sm-5">
        <label for="btb-dir-team" class="visually-hidden">Team</label>
        <select class="form-select" id="btb-dir-team">
          <option value="all">All teams</option>
          <option value="design">Design</option>
          <option value="engineering">Engineering</option>
          <option value="support">Support</option>
        </select>
      </div>
    </div>
    <p class="small text-body-secondary mb-2" role="status" id="btb-dir-status">Showing all 7 people</p>
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th scope="col">Name</th>
          <th scope="col">Role</th>
          <th scope="col" class="d-none d-sm-table-cell">Location</th>
        </tr>
      </thead>
      <tbody>
        <tr data-team="design"><td class="fw-medium">Aiko Tanaka</td><td>Product designer</td><td class="d-none d-sm-table-cell">Lisbon</td></tr>
        <tr data-team="engineering"><td class="fw-medium">Ben Carter</td><td>Frontend engineer</td><td class="d-none d-sm-table-cell">Leeds</td></tr>
        <tr data-team="support"><td class="fw-medium">Chloe Martin</td><td>Support lead</td><td class="d-none d-sm-table-cell">Lyon</td></tr>
        <tr data-team="engineering"><td class="fw-medium">Diego Ruiz</td><td>Backend engineer</td><td class="d-none d-sm-table-cell">Valencia</td></tr>
        <tr data-team="design"><td class="fw-medium">Eva Novak</td><td>Brand designer</td><td class="d-none d-sm-table-cell">Prague</td></tr>
        <tr data-team="support"><td class="fw-medium">Femi Adeyemi</td><td>Support engineer</td><td class="d-none d-sm-table-cell">Lagos</td></tr>
        <tr data-team="engineering"><td class="fw-medium">Grace Lin</td><td>Engineering manager</td><td class="d-none d-sm-table-cell">Toronto</td></tr>
        <tr class="btb-dir-empty" hidden><td colspan="3" class="text-center text-body-secondary py-4">No people match that search.</td></tr>
      </tbody>
    </table>
  </div>
</div>

Transactions with pagination

  • pagination
  • aria-current="page"
  • page-item disabled
  • hidden
  • table-sm

Use the page links under the table.

Fifteen rows are in the markup and the script shows five at a time by toggling the hidden property. The Bootstrap pagination below the table gets aria-label on its nav, .active plus aria-current="page" on the current page and .disabled with aria-disabled on Previous or Next at either end. A summary line states which rows are visible.

View code
<div class="card btb-paged border-0 shadow-sm" id="btb-paged">
  <div class="card-body p-3 p-sm-4">
    <h2 class="h6 mb-3">Account activity</h2>
    <table class="table table-sm align-middle">
      <thead>
        <tr>
          <th scope="col">Date</th>
          <th scope="col">Description</th>
          <th scope="col" class="text-end">Amount</th>
        </tr>
      </thead>
      <tbody>
        <tr><td>Oct 27</td><td>Studio rent</td><td class="text-end">-$1,450.00</td></tr>
        <tr><td>Oct 26</td><td>Client payment, Tallyfold</td><td class="text-end text-success-emphasis">+$3,200.00</td></tr>
        <tr><td>Oct 25</td><td>Font licence renewal</td><td class="text-end">-$129.00</td></tr>
        <tr><td>Oct 24</td><td>Coffee beans, wholesale</td><td class="text-end">-$86.40</td></tr>
        <tr><td>Oct 22</td><td>Client payment, Hollis Bakery</td><td class="text-end text-success-emphasis">+$980.00</td></tr>
        <tr><td>Oct 21</td><td>Cloud hosting</td><td class="text-end">-$48.00</td></tr>
        <tr><td>Oct 20</td><td>Printer toner</td><td class="text-end">-$62.15</td></tr>
        <tr><td>Oct 18</td><td>Client payment, Arlo Dental</td><td class="text-end text-success-emphasis">+$1,540.00</td></tr>
        <tr><td>Oct 17</td><td>Train tickets, Leeds</td><td class="text-end">-$74.80</td></tr>
        <tr><td>Oct 15</td><td>Stock photo credits</td><td class="text-end">-$39.00</td></tr>
        <tr><td>Oct 14</td><td>Client payment, Nordvik</td><td class="text-end text-success-emphasis">+$2,150.00</td></tr>
        <tr><td>Oct 12</td><td>Accounting software</td><td class="text-end">-$32.00</td></tr>
        <tr><td>Oct 10</td><td>Team lunch</td><td class="text-end">-$118.60</td></tr>
        <tr><td>Oct 8</td><td>Domain renewals</td><td class="text-end">-$54.00</td></tr>
        <tr><td>Oct 6</td><td>Client payment, Pine and Thread</td><td class="text-end text-success-emphasis">+$760.00</td></tr>
      </tbody>
    </table>
    <div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
      <p class="small text-body-secondary mb-0" id="btb-paged-summary" aria-live="polite">Showing 1 to 5 of 15</p>
      <nav aria-label="Account activity pages">
        <ul class="pagination pagination-sm mb-0">
          <li class="page-item disabled"><a class="page-link" href="#" data-btb-page="prev" aria-disabled="true" aria-label="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a></li>
          <li class="page-item active"><a class="page-link" href="#" data-btb-page="1" aria-current="page">1</a></li>
          <li class="page-item"><a class="page-link" href="#" data-btb-page="2">2</a></li>
          <li class="page-item"><a class="page-link" href="#" data-btb-page="3">3</a></li>
          <li class="page-item"><a class="page-link" href="#" data-btb-page="next" aria-label="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a></li>
        </ul>
      </nav>
    </div>
  </div>
</div>

Dark mode, theming and comparison tables

A table that follows data-bs-theme, a pricing comparison with a highlighted column and a table restyled entirely through the --bs-table-* custom properties.

Deploy history in dark mode

  • data-bs-theme="dark"
  • table-hover
  • --bs-table-bg
  • text-*-emphasis
  • font-monospace

The card carries data-bs-theme="dark", so it stays dark even on a light page. A plain .table follows the color mode because its color and background come from --bs-emphasis-color and --bs-body-bg, unlike .table-dark, which is a fixed variant. One scoped rule sets --bs-table-bg to transparent so the rows sit on the card color, and the status uses text-success-emphasis and text-danger-emphasis, which switch to lighter tones in dark mode.

View code
<div class="card btb-deploys" data-bs-theme="dark">
  <div class="card-header d-flex justify-content-between align-items-center py-3">
    <h2 class="h6 mb-0">Deployments, web-storefront</h2>
    <span class="badge text-bg-secondary">main</span>
  </div>
  <div class="table-responsive">
    <table class="table table-hover align-middle mb-0 text-nowrap">
      <thead>
        <tr>
          <th scope="col">Commit</th>
          <th scope="col">Environment</th>
          <th scope="col" class="d-none d-sm-table-cell">Duration</th>
          <th scope="col">Result</th>
        </tr>
      </thead>
      <tbody>
        <tr><td><code class="btb-sha">a91f3c2</code> <span class="d-none d-md-inline text-body-secondary">Fix cart badge count</span></td><td>Production</td><td class="d-none d-sm-table-cell">2m 14s</td><td class="text-success-emphasis"><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Passed</td></tr>
        <tr><td><code class="btb-sha">7d0be45</code> <span class="d-none d-md-inline text-body-secondary">Lazy-load product images</span></td><td>Staging</td><td class="d-none d-sm-table-cell">1m 58s</td><td class="text-success-emphasis"><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Passed</td></tr>
        <tr><td><code class="btb-sha">c3e9a10</code> <span class="d-none d-md-inline text-body-secondary">Upgrade payment SDK</span></td><td>Staging</td><td class="d-none d-sm-table-cell">0m 47s</td><td class="text-danger-emphasis"><i class="bi bi-x-circle-fill me-1" aria-hidden="true"></i>Failed</td></tr>
        <tr><td><code class="btb-sha">5b21f8e</code> <span class="d-none d-md-inline text-body-secondary">Add size guide modal</span></td><td>Preview</td><td class="d-none d-sm-table-cell">1m 32s</td><td class="text-warning-emphasis"><i class="bi bi-hourglass-split me-1" aria-hidden="true"></i>Running</td></tr>
      </tbody>
    </table>
  </div>
</div>

Pricing comparison with check icons

  • th scope="row"
  • Bootstrap Icons
  • visually-hidden
  • --bs-table-bg on cells
  • table-group-divider

Plans are column headers and features are row headers, so a screen reader announces both for every cell. Each check or dash icon is aria-hidden and paired with visually-hidden text (Included, Not included). The recommended column is highlighted by setting --bs-table-bg on its cells only, which the .table cell rule picks up without overriding any Bootstrap class.

View code
<div class="btb-price">
  <div class="table-responsive">
    <table class="table align-middle text-center mb-0">
      <caption class="caption-top text-start pt-0">
        <span class="btb-price-title d-block">Compare Tallyfold plans</span>
        Billed yearly, prices per user per month
      </caption>
      <thead>
        <tr>
          <th scope="col" class="text-start"><span class="visually-hidden">Feature</span></th>
          <th scope="col">Starter<span class="btb-price-num d-block">$0</span></th>
          <th scope="col" class="btb-pick">Team<span class="btb-price-num d-block">$9</span><span class="badge rounded-pill btb-price-badge">Popular</span></th>
          <th scope="col">Business<span class="btb-price-num d-block">$19</span></th>
        </tr>
      </thead>
      <tbody class="table-group-divider">
        <tr><th scope="row" class="text-start fw-normal">Projects</th><td>3</td><td class="btb-pick">Unlimited</td><td>Unlimited</td></tr>
        <tr><th scope="row" class="text-start fw-normal">Shared workspaces</th><td><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td><td class="btb-pick"><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td><td><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td></tr>
        <tr><th scope="row" class="text-start fw-normal">Custom fields</th><td><i class="bi bi-dash-lg btb-no" aria-hidden="true"></i><span class="visually-hidden">Not included</span></td><td class="btb-pick"><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td><td><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td></tr>
        <tr><th scope="row" class="text-start fw-normal">Audit log</th><td><i class="bi bi-dash-lg btb-no" aria-hidden="true"></i><span class="visually-hidden">Not included</span></td><td class="btb-pick"><i class="bi bi-dash-lg btb-no" aria-hidden="true"></i><span class="visually-hidden">Not included</span></td><td><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td></tr>
        <tr><th scope="row" class="text-start fw-normal">Single sign-on</th><td><i class="bi bi-dash-lg btb-no" aria-hidden="true"></i><span class="visually-hidden">Not included</span></td><td class="btb-pick"><i class="bi bi-dash-lg btb-no" aria-hidden="true"></i><span class="visually-hidden">Not included</span></td><td><i class="bi bi-check-lg btb-yes" aria-hidden="true"></i><span class="visually-hidden">Included</span></td></tr>
      </tbody>
      <tfoot>
        <tr>
          <td><span class="visually-hidden">Choose a plan</span></td>
          <td><a href="#" class="btn btn-sm btn-outline-dark btb-price-btn" aria-label="Choose Starter">Choose</a></td>
          <td class="btb-pick"><a href="#" class="btn btn-sm btb-price-cta btb-price-btn" aria-label="Choose Team">Choose</a></td>
          <td><a href="#" class="btn btn-sm btn-outline-dark btb-price-btn" aria-label="Choose Business">Choose</a></td>
        </tr>
      </tfoot>
    </table>
  </div>
</div>

Seed catalogue themed with --bs-table-* variables

  • --bs-table-bg
  • --bs-table-striped-bg
  • --bs-table-hover-bg
  • --bs-table-border-color
  • table-striped table-hover

No Sass build: one class on the table resets the component variables that .table defines, including --bs-table-color, --bs-table-bg, --bs-table-border-color, --bs-table-striped-bg and --bs-table-hover-bg, and the striped and hover classes keep working with the new colors. Because the hover color travels through --bs-table-bg-state, a hovered row still beats a striped one. The custom selector .btb-seeds.table outranks .table, so it wins wherever the stylesheet loads.

View code
<div class="btb-seed-wrap">
  <table class="table table-striped table-hover btb-seeds caption-top mb-0">
    <caption>
      <span class="btb-seed-title d-block">Hedgerow Seed Co., heirloom tomatoes</span>
      Sowing season 2027, packets of 25 seeds
    </caption>
    <thead>
      <tr>
        <th scope="col">Variety</th>
        <th scope="col" class="d-none d-sm-table-cell">Type</th>
        <th scope="col" class="text-end">Days</th>
        <th scope="col" class="text-end">Price</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">Brandywine Pink</th><td class="d-none d-sm-table-cell">Beefsteak</td><td class="text-end">85</td><td class="text-end">$4.25</td></tr>
      <tr><th scope="row">Black Krim</th><td class="d-none d-sm-table-cell">Beefsteak</td><td class="text-end">80</td><td class="text-end">$4.25</td></tr>
      <tr><th scope="row">Sungold Heirloom</th><td class="d-none d-sm-table-cell">Cherry</td><td class="text-end">65</td><td class="text-end">$3.75</td></tr>
      <tr><th scope="row">San Marzano</th><td class="d-none d-sm-table-cell">Paste</td><td class="text-end">78</td><td class="text-end">$3.95</td></tr>
      <tr><th scope="row">Green Zebra</th><td class="d-none d-sm-table-cell">Salad</td><td class="text-end">75</td><td class="text-end">$4.10</td></tr>
    </tbody>
  </table>
</div>

What are Bootstrap tables?

Everything starts from .table, and every modifier stacks on top of it. The opt-in design is deliberate. The Bootstrap 5.3 documentation (v5.3.8 at the time of writing) points out that tables show up inside third-party widgets such as calendars and date pickers, so the Bootstrap framework leaves unclassed tables alone.

An unclassed table still picks up Reboot, the framework’s baseline layer. Reboot styles captions, collapses borders and sets a consistent text alignment, and that’s all it does.

Padding, dividers and every visual state come from classes. Skip them and you are back to writing table CSS by hand.

At minimum you want a table element with .table on it, a thead whose th scope=”col” cells label the columns, and a tbody. Where a row has its own label, use th scope=”row” for it. A tfoot is optional, handy for totals.

With .table applied, cells get .5rem of padding and light horizontal dividers.

Markup is where a lot of sites slip. WebAIM’s 2026 Million report counted 948,225 tables across 143,575 home pages, and only 19% had valid data table markup.

The classes won’t repair that. Bootstrap’s accessibility docs say a project’s accessibility depends largely on the author’s markup, which makes the scope attributes above worth the typing.

Table style classes: stripes, hover, borders and size

Most styling comes down to a handful of classes you add next to .table: striped rows, striped columns, hover, borders and compact size.

ClassEffectTargetsDefault detail
.table-stripedZebra stripestbody rowsOdd rows shaded
.table-striped-columnsZebra stripesColumnsEven columns shaded
.table-hoverHover statetbody rowsWorks with stripes
.table-bordered / .table-borderlessAdds or removes bordersTable and cellsRecolor with border utilities
.table-smCompact spacingAll cellsPadding cut from .5rem to .25rem

Stacking modifiers

Modifiers combine freely. The Bootstrap docs show .table-striped next to .table-hover, .table-dark with either .table-striped or .table-striped-columns, and .table-success with .table-striped-columns.

.table-active is the odd one out. It highlights one row or one cell, so it goes on a tr, td or th instead of the table.

Choosing between them

Stripes earn their place on long tables, where the eye loses its row. On short ones they just add noise, so I’d skip them.

For dense data, .table-sm with .table-borderless fits more rows on screen.

Wide numeric grids are different. .table-bordered earns its heavier look when readers track values across many columns.

How do contextual colors work on Bootstrap tables?

Contextual colors use the .table- prefix, as in .table-danger, and the names are primary, secondary, success, danger, warning, info, light and dark.

Put .table-primary on the table element and every cell picks it up. On a tr, .table-warning colors that row only. And .table-danger on a td or th colors a single cell.

Head and foot colors

.table-light or .table-dark on the thead turns the header row light or dark gray. In Bootstrap 4 that job belonged to .thead-light and .thead-dark, which Bootstrap 5.0.0 dropped.

The variants also work on tfoot, so a totals row can carry its own color.

Where colored tables fall short

Dark mode catches people off guard. Per the Bootstrap 5.3 docs, the contextual variants most likely will not get color mode adaptive styling until v6, so they will not follow a dark color mode.

Contrast is the next problem. The Bootstrap accessibility docs warn that some pairings in the default palette fall below the WCAG 2.2 text ratio of 4.5:1, and the table variants are generated from those same theme colors.

Bootstrap picks each variant’s text color with its color-contrast() function, but that is no substitute for a color contrast check on every variant you ship.

WebAIM’s 2026 data shows how common the problem is. Low contrast text appeared on 83.9% of the million home pages it scanned. Pages running Bootstrap (240,869 of them) averaged 63.3 detected errors, against 56.1 across the full sample, though WebAIM cautions that extra errors cannot always be attributed to the technology itself.

Then there’s meaning. Color alone does not reach screen reader users, so the docs ask for the meaning to sit in visible text or in a .visually-hidden span.

My own rule is to use a row variant for status only when the row’s text also states the status.

Captions, group dividers, alignment and nesting in Bootstrap tables

The structural side of a table (caption, group dividers, vertical alignment, nesting) takes small classes or markup choices, not a custom stylesheet.

Footers and captions

A caption works as a heading for the table. The docs note that it helps screen reader users find a table and decide whether to read it, which is the main reason to add one to any accessible table.

The caption sits below the table by default. Add .caption-top to move it above. It renders in the secondary text color, set by $table-caption-color.

A tfoot works like thead and tbody. It holds totals or summary rows, and the same .table rules style its cells.

Group dividers

.table-group-divider adds a thicker, darker border between the thead, tbody and tfoot groups. To change its color, set border-top-color on the group, since Bootstrap ships no utility class for it.

It’s opt-in as of Bootstrap 5.2.0, which removed the thicker default border between groups.

Vertical alignment

Header cells sit at the bottom and body cells at the top. The top default comes from the $table-cell-vertical-align Sass variable.

.align-middle on the table recenters the body cells (header cells stay at the bottom). To move one row, put .align-bottom on its tr, and .align-top on a td fixes a single cell.

Nested tables

Borders, active styles and variants do not pass down to a nested table. Bootstrap 5 targets cells with the child-combinator selector .table > :not(caption) > * > *, so only the outer table’s own cells match.

Put the inner table in a td with a colspan, and add .mb-0 to drop its bottom margin.

How to make a Bootstrap table responsive

Wrap the table in a div with .table-responsive. The table then scrolls horizontally inside the wrapper whenever it is wider than the screen, at every viewport width.

  1. Add .table to the table element.
  2. Wrap the table in a div carrying .table-responsive, or a breakpoint version such as .table-responsive-md.
  3. Pick the breakpoint suffix: -sm, -md, -lg, -xl or -xxl.
  4. Test below that width, because breakpoint tables can look broken until their responsive styles apply.

Breakpoint widths

A breakpoint class scrolls the table up to its width. From the breakpoint and above, the table behaves normally.

Wrapper classScrolls below
.table-responsive-sm576px
.table-responsive-md768px
.table-responsive-lg992px
.table-responsive-xl1200px
.table-responsive-xxl1400px

Those are the same widths Bootstrap uses in its media queries for the grid and components, taken from the $grid-breakpoints Sass map in the Bootstrap 5.3 breakpoints docs.

The -xxl option exists because Bootstrap 5.0.0 added the 1400px breakpoint. Bootstrap 4 stops at -xl.

Bootstrap’s CSS is built mobile first, so the scroll behavior applies at the small end and releases as the screen grows.

There’s one side effect. The Bootstrap 5.3 docs say responsive wrappers use overflow-y: hidden, which clips dropdown menus and other widgets placed inside cells. The compiled 5.3.8 CSS only sets overflow-x: auto, but that turns vertical overflow into auto as well, so the clipping is the same.

Why does .table-responsive clip dropdown menus?

The wrapper that scrolls the table also cuts off anything poking out past its top or bottom edge, and a menu opening from a cell does exactly that.

Row-level actions built as a Bootstrap dropdown are the usual victim. The Bootstrap 4.6 docs carry the same warning as the 5.3 docs, so the limit is old, not a version 5 regression.

Stop scrolling at wide widths

Swap .table-responsive for a breakpoint class such as .table-responsive-lg. From that breakpoint up, the table behaves normally, so no scroll container exists to clip the menu.

Menus then open fully on laptops and desktops. Below the breakpoint they are still clipped, because the wrapper scrolls there.

Community CSS overrides

A snippet shared on DC Blog sets .dropdown-menu inside .table-responsive to position: static below 768px. From 768px up, it sets .table-responsive to overflow: visible.

That is a community workaround, not Bootstrap guidance.

It comes with a cost. A static menu opens inside the cell and pushes the row taller, and overflow: visible removes scrolling at that width, so a wide table can run past the page edge.

When the clipping does not apply

Tables with plain text cells never hit it.

The trouble starts only when a cell holds a menu or any widget that draws outside the cell box.

How to customize Bootstrap tables with CSS variables and Sass

Use CSS variables to restyle one table at runtime and Sass variables to change the defaults for a whole build. Both routes feed the same –bs-table-* properties that Bootstrap 5 assigns at the .table base class.

How the accent color is built

Each cell takes its background from –bs-table-bg. An inset box shadow of 0 0 0 9999px then paints the accent color on top, which is why stripes and hover states work over any variant, semi-transparent backgrounds included.

–bs-table-accent-bg is transparent by default, so a plain table casts no shadow at all.

.table-striped and .table-striped-columns point –bs-table-bg-type at –bs-table-striped-bg. .table-hover and .table-active point –bs-table-bg-state at –bs-table-hover-bg or –bs-table-active-bg.

The shadow reads var(–bs-table-bg-state, var(–bs-table-bg-type, var(–bs-table-accent-bg))), so state outranks type, and a hovered row beats a striped one regardless of CSS specificity.

Overriding one table with CSS variables

Bootstrap assigns its component variables at the base class, so a custom class on the table can reset them without recompiling Sass. Load that class after Bootstrap’s stylesheet, or give it higher specificity.

The variables you’ll reach for most are –bs-table-striped-bg for stripe color, –bs-table-hover-bg for the hover shade, –bs-table-border-color for dividers and –bs-table-color for text.

Wider changes follow the same logic as any guide to overriding Bootstrap CSS: scope the rule, load it late, keep it small.

Changing defaults in Sass

Sass overrides go after the functions import and before the variables import. The Bootstrap 5.2.0 migration notes explain why: once a default has been used, Sass cannot update it.

The factor variables set how strong each accent looks in every variant. In Bootstrap 5.3.8, $table-striped-bg-factor is .05, $table-hover-bg-factor is .075, $table-active-bg-factor is .1 and $table-border-factor is .2. $table-bg-scale sits at -80%, which lightens theme colors for variant backgrounds (light and dark excepted).

New variants come from the $table-variants map, which feeds a Sass loop that outputs one .table-* class per entry.

It stops short in places, though. The docs list stripe order only among the Sass variables ($table-striped-order and $table-striped-columns-order), and the 5.2.0 notes say CSS variable coverage stays incomplete until Bootstrap 6.

What changed in Bootstrap tables from version 4 to version 5?

The big shift came in Bootstrap 5.0.0, when tables were rebuilt on CSS variables. Nested tables also stopped inheriting parent styles, and the cell padding variable was split in two. Striped columns came later, in 5.2.0.

The class-level changes (head colors, the -xxl breakpoint, group dividers) sit in the sections above. The rows below cover what changed underneath.

ChangeBootstrap 4Bootstrap 5 (release)
Nested tablesInherit parent stylesDo not inherit (5.0.0)
Style sourceNot built on CSS variablesRebuilt on CSS variables (5.0.0)
Cell padding variablesSingle variableSplit into -y and -x (5.0.0)
Striped columnsNot in the 4.6 docs.table-striped-columns (5.2.0)
Variant mixintable-row-variant()table-variant(), two parameters (5.0.0)

What to check when upgrading

Nested tables that leaned on inherited borders or variants will look different. Custom Sass calling table-row-variant() needs updating, and so do overrides of the old single padding variable.

Bootstrap 4 has reached end of life, according to the banner on its own docs. A version 4 project is better migrated than patched.

Where the docs disagree

The Bootstrap 4.6 tables page puts the -xl scroll cutoff at 1120px, but Bootstrap 4’s own $grid-breakpoints map sets xl at 1200px (and its widest container at 1140px), the same 1200px the 5.3 breakpoints page lists. The 1120px figure looks like a docs typo, so read the compiled CSS of your own build if you need the exact number.

One more dated detail from inside version 5: the 5.3.0-alpha2 release reverted the border-width utilities to set their property directly, which avoids inheritance problems across nested elements, tables included.

Sorting, search and pagination: when Bootstrap tables need a plugin

Bootstrap’s table classes only style markup, and the table docs describe no sorting, filtering or paging. Those features need a plugin, and the two common choices are DataTables and the Bootstrap Table plugin.

Because the classes are CSS only, every interactive behavior has to come from JavaScript loaded next to them.

DataTables

DataTables adds pagination, instant search and multi-column ordering to an existing HTML table, and it ships Bootstrap 3, 4 and 5 styling integrations.

jQuery is no longer required as of DataTables 3.0.0, which its release post describes as having no dependencies. DataTables 3 was released on 24 July 2026, per the DataTables blog, and the core is MIT licensed.

The Bootstrap Table plugin

The Bootstrap Table plugin (v1.28.0 at the time of writing) switches features on through data attributes such as data-toggle=”table”, data-pagination, data-search and data-sortable. It can also load rows from a remote JSON file with data-url.

It targets Bootstrap v5 and uses Bootstrap Icons, and jQuery must load before it, per its script order. Extensions include Sticky Header, Export, Fixed Columns and Multiple Sort.

How to choose

Stay with plain classes for display-only data. Add a plugin only when readers must sort, search or page.

If you’re starting fresh with no jQuery on the page, DataTables is the better fit. Bootstrap Table makes more sense when jQuery is already loaded and you like setting things up through attributes.

In WebAIM’s 2026 Million report, the 6,724 home pages running DataTables averaged 69.6 detected errors, against 56.1 across the full sample.

WebAIM cautions that extra errors cannot always be pinned on the technology itself. Either way, give a plugin-rendered table a full web accessibility pass, and confirm the .table classes and the .table-responsive wrapper survive initialization.

Bootstrap Tables FAQ

How do I set column widths in a Bootstrap table?

Put a sizing utility on the header cell of that column. Bootstrap’s width utilities cover 25%, 50%, 75%, 100% and auto (.w-25, .w-50, .w-75, .w-100, .w-auto), and the tables docs list no dedicated column width class.

Can I center or right-align text in table cells?

Yes. Add .text-center or .text-end to a td or th, and .text-start to restore left alignment.

Responsive forms such as .text-md-end apply from that breakpoint up. Bootstrap deliberately ships no justified-text class, because justification makes word spacing irregular.

Is there a sticky header class for Bootstrap tables?

No. The Bootstrap docs describe .sticky-top for elements in general, not for table headers.

Community examples set position: sticky; top: 0 on the th cells, and give the wrapper a fixed height with vertical scrolling when the table sits inside one. The Bootstrap Table plugin also offers a Sticky Header extension.

How can I hide a column on small screens?

Add .d-none and .d-md-table-cell to every th and td in that column. The column disappears below the md breakpoint (768px) and returns from md up.

Use the table-cell value, not block, so the cell stays a table cell.

What to Check Before a Bootstrap Table Goes Live

Before shipping, go through the markup first, then the scroll wrapper, then the variant colors, in that order. Make sure th scope and a caption are in place, that the wrapper has been tested with menus open, and that each variant clears a contrast check.

Markup goes first because scope attributes and a caption cost no CSS and serve every screen reader user.

The wrapper comes second because it decides whether widgets in cells render at all. Colors come last, since a variant swaps in without touching structure.

The contrast check has a price. A failing variant sometimes needs overriding, which gives up the one-class convenience of .table-success and its siblings and adds a stylesheet to maintain.

This order was verified against Bootstrap 5.3.8 in October 2026. A stable Bootstrap 6 release would reopen the last check, and the sequence should be re-run against its table docs.