Tailwind Table: 16 Examples, From Striped to Sortable

Copy Tailwind's table-fixed example into a project and the layout may not change at all. table-fixed has no effect on a table whose width is auto (MDN), so a width class such as w-full has to sit next to it.

Below are 16 Tailwind table examples you can copy, followed by a guide to layout utilities, responsive patterns, sticky headers and accessibility.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Table structure, rows and cells

Semantic tables styled only with utilities: captions, scope, tfoot totals, tabular figures, odd: and even: stripes, hover rows, status badges, a density switch and a pricing comparison.

Invoice with tfoot totals

  • tabular-nums
  • tfoot
  • scope
  • divide-y
  • hidden sm:table-cell

A real table with a caption, column headers marked scope="col" and a tfoot whose labels are row headers. tabular-nums gives every digit the same width so the amounts line up under text-right, and divide-y with divide-stone-200 names the line color, because v4 borders default to currentColor. The Rate column uses hidden sm:table-cell, so the tfoot labels come in two copies with matching colspan values for each breakpoint.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-200 sm:p-8">
  <div class="flex flex-wrap items-start justify-between gap-4">
    <div>
      <h2 class="text-lg font-semibold tracking-tight text-stone-900">Halden &amp; Rowe Studio</h2>
      <p class="mt-0.5 text-sm text-stone-600">Invoice HR-2026-0193, issued 21 Oct 2026</p>
    </div>
    <div class="text-sm sm:text-right">
      <p class="text-stone-600">Billed to</p>
      <p class="font-medium text-stone-900">Bramblewood Bakery</p>
    </div>
  </div>
  <table class="mt-6 w-full border-collapse text-left text-sm tabular-nums">
    <caption class="sr-only">Invoice line items, amounts in US dollars</caption>
    <thead class="border-b border-stone-300 text-xs uppercase tracking-wide text-stone-600">
      <tr>
        <th scope="col" class="py-2 pr-3 font-semibold">Item</th>
        <th scope="col" class="px-3 py-2 text-right font-semibold">Qty</th>
        <th scope="col" class="hidden px-3 py-2 text-right font-semibold sm:table-cell">Rate</th>
        <th scope="col" class="py-2 pl-3 text-right font-semibold">Amount</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-stone-200 text-stone-800">
      <tr>
        <td class="py-3 pr-3"><span class="block font-medium text-stone-900">Brand refresh workshop</span><span class="text-stone-600">Two half-day sessions</span></td>
        <td class="px-3 py-3 text-right">2</td>
        <td class="hidden px-3 py-3 text-right sm:table-cell">$720.00</td>
        <td class="py-3 pl-3 text-right">$1,440.00</td>
      </tr>
      <tr>
        <td class="py-3 pr-3"><span class="block font-medium text-stone-900">Packaging system</span><span class="text-stone-600">Box, sleeve and sticker set</span></td>
        <td class="px-3 py-3 text-right">1</td>
        <td class="hidden px-3 py-3 text-right sm:table-cell">$2,150.00</td>
        <td class="py-3 pl-3 text-right">$2,150.00</td>
      </tr>
      <tr>
        <td class="py-3 pr-3"><span class="block font-medium text-stone-900">Menu board layout</span><span class="text-stone-600">Print-ready PDF</span></td>
        <td class="px-3 py-3 text-right">3</td>
        <td class="hidden px-3 py-3 text-right sm:table-cell">$185.00</td>
        <td class="py-3 pl-3 text-right">$555.00</td>
      </tr>
      <tr>
        <td class="py-3 pr-3"><span class="block font-medium text-stone-900">Revisions</span><span class="text-stone-600">Billed hourly</span></td>
        <td class="px-3 py-3 text-right">6</td>
        <td class="hidden px-3 py-3 text-right sm:table-cell">$95.00</td>
        <td class="py-3 pl-3 text-right">$570.00</td>
      </tr>
    </tbody>
    <tfoot class="border-t border-stone-300 text-stone-700">
      <tr>
        <th scope="row" colspan="2" class="pt-4 pr-3 text-right font-normal sm:hidden">Subtotal</th>
        <th scope="row" colspan="3" class="hidden pt-4 pr-3 text-right font-normal sm:table-cell">Subtotal</th>
        <td class="pt-4 pl-3 text-right">$4,715.00</td>
      </tr>
      <tr>
        <th scope="row" colspan="2" class="pt-1 pr-3 text-right font-normal sm:hidden">Sales tax 8%</th>
        <th scope="row" colspan="3" class="hidden pt-1 pr-3 text-right font-normal sm:table-cell">Sales tax 8%</th>
        <td class="pt-1 pl-3 text-right">$377.20</td>
      </tr>
      <tr class="text-base text-stone-900">
        <th scope="row" colspan="2" class="pt-3 pr-3 text-right font-semibold sm:hidden">Total due</th>
        <th scope="row" colspan="3" class="hidden pt-3 pr-3 text-right font-semibold sm:table-cell">Total due</th>
        <td class="pt-3 pl-3 text-right font-semibold"><span class="rounded-md bg-amber-100 px-2 py-1 text-amber-900">$5,092.20</span></td>
      </tr>
    </tfoot>
  </table>
  <p class="mt-6 border-t border-dashed border-stone-300 pt-4 text-xs text-stone-600">Payment due within 14 days. Bank transfer reference HR-0193.</p>
</div>

Striped orders with status badges

  • odd:
  • even:
  • hover:
  • ring-inset
  • md:table-cell

Each tr carries odd:bg-white even:bg-slate-50 hover:bg-sky-50, the stripe pattern from the Tailwind docs plus a hover highlight that v4 only applies on devices that can hover. Badges are inline-flex pills with a tinted background and a ring-1 ring-inset border, and the status is written out so color is never the only signal. The customer name moves under the order number on phones and gets its own column from md: up.

View code
<div class="w-full max-w-3xl overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
  <div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3 sm:px-5">
    <h2 class="text-base font-semibold text-slate-900">Recent orders</h2>
    <a href="#" class="rounded-md px-2 py-1 text-sm font-medium text-sky-700 hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-sky-600">View all</a>
  </div>
  <div class="overflow-x-auto">
    <table class="w-full text-left text-sm">
      <caption class="sr-only">Six most recent orders with customer, date, status and total</caption>
      <thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-600">
        <tr>
          <th scope="col" class="px-4 py-3 sm:px-5">Order</th>
          <th scope="col" class="hidden px-4 py-3 md:table-cell">Customer</th>
          <th scope="col" class="hidden px-4 py-3 md:table-cell">Date</th>
          <th scope="col" class="px-4 py-3">Status</th>
          <th scope="col" class="px-4 py-3 text-right sm:px-5">Total</th>
        </tr>
      </thead>
      <tbody class="text-slate-700">
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40318</span><span class="block text-slate-600 md:hidden">Ines Carvalho</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Ines Carvalho</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 28, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-800 ring-1 ring-emerald-600/20 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-emerald-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Paid</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$248.00</td>
        </tr>
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40317</span><span class="block text-slate-600 md:hidden">Tomasz Wierzba</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Tomasz Wierzba</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 28, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-amber-600/25 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-amber-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Pending</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$1,092.50</td>
        </tr>
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40316</span><span class="block text-slate-600 md:hidden">Akosua Mensah</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Akosua Mensah</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 27, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-sky-50 px-2 py-0.5 text-xs font-medium text-sky-800 ring-1 ring-sky-600/20 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-sky-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Shipped</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$86.40</td>
        </tr>
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40315</span><span class="block text-slate-600 md:hidden">Lena Hoffmann</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Lena Hoffmann</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 26, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 ring-1 ring-slate-500/20 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-slate-400" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Refunded</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$310.00</td>
        </tr>
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40314</span><span class="block text-slate-600 md:hidden">Rafael Ortiz</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Rafael Ortiz</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 26, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-800 ring-1 ring-red-600/20 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-red-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Failed</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$59.99</td>
        </tr>
        <tr class="odd:bg-white even:bg-slate-50 hover:bg-sky-50">
          <td class="px-4 py-3 sm:px-5"><span class="font-medium text-slate-900">#40313</span><span class="block text-slate-600 md:hidden">Mei Tanaka</span></td>
          <td class="hidden px-4 py-3 md:table-cell">Mei Tanaka</td>
          <td class="hidden px-4 py-3 md:table-cell">Oct 25, 2026</td>
          <td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-800 ring-1 ring-emerald-600/20 ring-inset"><svg viewBox="0 0 6 6" class="size-1.5 fill-emerald-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Paid</span></td>
          <td class="px-4 py-3 text-right font-medium text-slate-900 tabular-nums sm:px-5">$742.15</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Compact density switch

  • group-data-[density=compact]:
  • aria-pressed:
  • font-mono
  • motion-safe:animate-pulse

Switch between Comfortable and Compact.

The table is a group with data-density on it, and every cell pairs py-3 with group-data-[density=compact]:py-1.5, so one attribute change tightens the whole table. The two buttons are a toggle pair styled with aria-pressed:bg-lime-300, and the JS only flips the attribute and aria-pressed. The running dot pulses inside motion-safe: and stays still for reduced motion.

View code
<div class="w-full max-w-3xl rounded-xl bg-zinc-900 text-zinc-200 ring-1 ring-white/10">
  <div class="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
    <h2 class="text-sm font-semibold text-white">Deploy history <span class="font-normal text-zinc-400">web-storefront</span></h2>
    <div class="inline-flex rounded-lg bg-zinc-800 p-1 text-xs font-medium" role="group" aria-label="Row density">
      <button type="button" class="ttb-density-btn min-h-8 rounded-md px-3 text-zinc-300 hover:text-white focus-visible:outline-2 focus-visible:outline-lime-300 aria-pressed:bg-lime-300 aria-pressed:text-zinc-900" data-value="comfortable" aria-pressed="true">Comfortable</button>
      <button type="button" class="ttb-density-btn min-h-8 rounded-md px-3 text-zinc-300 hover:text-white focus-visible:outline-2 focus-visible:outline-lime-300 aria-pressed:bg-lime-300 aria-pressed:text-zinc-900" data-value="compact" aria-pressed="false">Compact</button>
    </div>
  </div>
  <div class="overflow-x-auto">
    <table id="ttb-deploys" class="group w-full text-left text-sm data-[density=compact]:text-xs" data-density="comfortable">
      <caption class="sr-only">Last seven deploys of web-storefront</caption>
      <thead class="text-xs text-zinc-400">
        <tr>
          <th scope="col" class="px-4 py-3 font-medium group-data-[density=compact]:py-1.5">Commit</th>
          <th scope="col" class="hidden px-4 py-3 font-medium group-data-[density=compact]:py-1.5 sm:table-cell">Branch</th>
          <th scope="col" class="px-4 py-3 font-medium group-data-[density=compact]:py-1.5">Status</th>
          <th scope="col" class="px-4 py-3 text-right font-medium group-data-[density=compact]:py-1.5">Time</th>
          <th scope="col" class="hidden px-4 py-3 text-right font-medium group-data-[density=compact]:py-1.5 sm:table-cell">Started</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-white/5">
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">a3f91c2</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Cache product images at the edge</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">main</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-sky-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-sky-400 motion-safe:animate-pulse" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Building</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">0:48</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">1 min ago</td>
        </tr>
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">7be04d9</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Fix coupon rounding on carts</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">main</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-lime-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-lime-400" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Ready</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">1:52</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">42 min ago</td>
        </tr>
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">e19c6a0</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Add gift wrap option</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">feat/gift-wrap</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-rose-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-rose-400" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Failed</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">0:31</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">2 h ago</td>
        </tr>
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">0d4b7f3</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Upgrade checkout SDK</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">main</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-lime-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-lime-400" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Ready</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">2:07</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">Yesterday</td>
        </tr>
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">c82a115</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Translate size guide to Polish</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">i18n/pl</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-zinc-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-zinc-500" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Canceled</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">0:12</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">Yesterday</td>
        </tr>
        <tr class="hover:bg-white/5">
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><code class="font-mono text-lime-200">5f30e8b</code><span class="block text-zinc-400 group-data-[density=compact]:hidden">Lazy-load reviews widget</span></td>
          <td class="hidden px-4 py-3 font-mono text-zinc-300 group-data-[density=compact]:py-1.5 sm:table-cell">main</td>
          <td class="px-4 py-3 group-data-[density=compact]:py-1.5"><span class="inline-flex items-center gap-1.5 text-lime-300"><svg viewBox="0 0 6 6" class="size-1.5 fill-lime-400" aria-hidden="true"><circle cx="3" cy="3" r="3"/></svg>Ready</span></td>
          <td class="px-4 py-3 text-right font-mono tabular-nums text-zinc-300 group-data-[density=compact]:py-1.5">1:44</td>
          <td class="hidden px-4 py-3 text-right text-zinc-400 group-data-[density=compact]:py-1.5 sm:table-cell">Oct 27</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Pricing comparison table

  • scope="row"
  • bg-violet-50
  • sr-only
  • text-balance
  • overflow-x-auto

Plans are column headers and features are row headers with scope="row", so a screen reader announces "Studio, Version history, 90 days" for each cell. The recommended column gets bg-violet-50 on every cell plus a visible pill, and check or dash icons carry sr-only text such as Included. Price figures switch to text-xs on phones so four columns still fit, and the overflow-x-auto wrapper is the safety net.

View code
<div class="w-full max-w-3xl rounded-2xl bg-white p-3 shadow-sm ring-1 ring-violet-950/10 sm:p-6">
  <div class="overflow-x-auto">
    <table class="w-full text-left text-xs text-zinc-700 sm:text-sm">
      <caption class="pb-4 text-left text-base font-semibold text-zinc-900 sm:text-lg">Compare Foliobase plans <span class="block text-sm font-normal text-zinc-600">Prices per month, billed yearly</span></caption>
      <thead>
        <tr class="align-bottom">
          <td class="w-[34%] pb-3"></td>
          <th scope="col" class="px-1.5 pb-3 text-center sm:px-3"><span class="block font-semibold text-zinc-900">Starter</span><span class="mt-1 block text-lg font-bold text-zinc-900 tabular-nums sm:text-2xl">$0</span></th>
          <th scope="col" class="rounded-t-xl bg-violet-50 px-1.5 pt-3 pb-3 text-center sm:px-3"><span class="block font-semibold text-violet-900">Studio <span class="mt-1 block w-fit mx-auto rounded-full bg-violet-600 px-2 py-0.5 text-[0.6875rem] font-semibold text-white sm:ml-1 sm:inline">Popular</span></span><span class="mt-1 block text-lg font-bold text-violet-900 tabular-nums sm:text-2xl">$14</span></th>
          <th scope="col" class="px-1.5 pb-3 text-center sm:px-3"><span class="block font-semibold text-zinc-900">Agency</span><span class="mt-1 block text-lg font-bold text-zinc-900 tabular-nums sm:text-2xl">$39</span></th>
        </tr>
      </thead>
      <tbody class="divide-y divide-zinc-200 border-t border-zinc-200">
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">Portfolio sites</th>
          <td class="px-1.5 py-3 text-center sm:px-3">1</td>
          <td class="bg-violet-50 px-1.5 py-3 text-center font-medium text-violet-950 sm:px-3">10</td>
          <td class="px-1.5 py-3 text-center sm:px-3">Unlimited</td>
        </tr>
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">Team seats</th>
          <td class="px-1.5 py-3 text-center sm:px-3">1</td>
          <td class="bg-violet-50 px-1.5 py-3 text-center font-medium text-violet-950 sm:px-3">3</td>
          <td class="px-1.5 py-3 text-center sm:px-3">15</td>
        </tr>
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">Version history</th>
          <td class="px-1.5 py-3 text-center sm:px-3">7 days</td>
          <td class="bg-violet-50 px-1.5 py-3 text-center font-medium text-violet-950 sm:px-3">90 days</td>
          <td class="px-1.5 py-3 text-center sm:px-3">Forever</td>
        </tr>
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">Custom domain</th>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-zinc-400 stroke-2" aria-hidden="true"><path d="M5 10h10"/></svg><span class="sr-only">Not included</span></td>
          <td class="bg-violet-50 px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-violet-600 stroke-2" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="sr-only">Included</span></td>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-violet-600 stroke-2" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="sr-only">Included</span></td>
        </tr>
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">Client proofing</th>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-zinc-400 stroke-2" aria-hidden="true"><path d="M5 10h10"/></svg><span class="sr-only">Not included</span></td>
          <td class="bg-violet-50 px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-violet-600 stroke-2" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="sr-only">Included</span></td>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-violet-600 stroke-2" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="sr-only">Included</span></td>
        </tr>
        <tr>
          <th scope="row" class="py-3 pr-2 font-medium text-zinc-900">White-label exports</th>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-zinc-400 stroke-2" aria-hidden="true"><path d="M5 10h10"/></svg><span class="sr-only">Not included</span></td>
          <td class="bg-violet-50 px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-zinc-400 stroke-2" aria-hidden="true"><path d="M5 10h10"/></svg><span class="sr-only">Not included</span></td>
          <td class="px-1.5 py-3 text-center sm:px-3"><svg viewBox="0 0 20 20" class="mx-auto size-5 fill-none stroke-violet-600 stroke-2" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="sr-only">Included</span></td>
        </tr>
      </tbody>
      <tfoot>
        <tr>
          <td></td>
          <td class="px-1 pt-4 text-center sm:px-3"><a href="#" class="inline-flex min-h-11 w-full items-center justify-center rounded-lg px-2 font-semibold text-violet-700 ring-1 ring-violet-200 hover:bg-violet-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600">Start<span class="sr-only"> with Starter</span></a></td>
          <td class="rounded-b-xl bg-violet-50 px-1 pt-4 pb-3 text-center sm:px-3"><a href="#" class="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-violet-600 px-2 font-semibold text-white hover:bg-violet-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600">Choose<span class="sr-only"> Studio</span></a></td>
          <td class="px-1 pt-4 text-center sm:px-3"><a href="#" class="inline-flex min-h-11 w-full items-center justify-center rounded-lg px-2 font-semibold text-violet-700 ring-1 ring-violet-200 hover:bg-violet-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600">Choose<span class="sr-only"> Agency</span></a></td>
        </tr>
      </tfoot>
    </table>
  </div>
</div>

Responsive and scrolling tables

Four ways to keep a wide table usable on a phone without pushing the page sideways: its own overflow-x-auto box, a sticky header inside a capped scroll area, rows that stack into cards and columns driven by container queries.

Scroll box with a sticky first column

  • overflow-x-auto
  • whitespace-nowrap
  • sticky left-0
  • min-w-full
  • focus-visible:

The table keeps its natural width with min-w-full and whitespace-nowrap, and only the overflow-x-auto wrapper scrolls, so the page never moves sideways. The region row headers use sticky left-0 with a solid bg-white, otherwise scrolled figures would show through. The wrapper is a labelled region with tabindex="0" and a focus-visible: outline, so keyboard users can scroll it with the arrow keys.

View code
<div class="w-full max-w-3xl">
  <div class="mb-3 flex flex-wrap items-end justify-between gap-2">
    <h2 id="ttb-kpi-title" class="text-base font-semibold text-teal-950">Revenue by region, 2026</h2>
    <p class="text-xs text-teal-800 md:hidden">Swipe the table to see every quarter</p>
  </div>
  <div class="overflow-x-auto rounded-xl bg-white ring-1 ring-teal-900/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600" role="region" aria-labelledby="ttb-kpi-title" tabindex="0">
    <table class="min-w-full text-sm whitespace-nowrap text-teal-950 tabular-nums">
      <thead class="bg-teal-50 text-xs font-semibold text-teal-900">
        <tr>
          <th scope="col" class="sticky left-0 z-10 bg-teal-50 px-4 py-3 text-left">Region</th>
          <th scope="col" class="px-4 py-3 text-right">Q1</th>
          <th scope="col" class="px-4 py-3 text-right">Q2</th>
          <th scope="col" class="px-4 py-3 text-right">Q3</th>
          <th scope="col" class="px-4 py-3 text-right">Q4 forecast</th>
          <th scope="col" class="px-4 py-3 text-right">Year to date</th>
          <th scope="col" class="px-4 py-3 text-right">Orders</th>
          <th scope="col" class="px-4 py-3 text-right">Avg order</th>
          <th scope="col" class="px-4 py-3 text-right">Change</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-teal-900/10">
        <tr>
          <th scope="row" class="sticky left-0 z-10 border-r border-teal-900/10 bg-white px-4 py-3 text-left font-medium">Nordics</th>
          <td class="px-4 py-3 text-right">$412k</td><td class="px-4 py-3 text-right">$458k</td><td class="px-4 py-3 text-right">$497k</td><td class="px-4 py-3 text-right text-teal-700">$530k</td><td class="px-4 py-3 text-right font-medium">$1.37M</td><td class="px-4 py-3 text-right">9,812</td><td class="px-4 py-3 text-right">$139.60</td><td class="px-4 py-3 text-right text-emerald-700">+12.4%</td>
        </tr>
        <tr>
          <th scope="row" class="sticky left-0 z-10 border-r border-teal-900/10 bg-white px-4 py-3 text-left font-medium">Iberia</th>
          <td class="px-4 py-3 text-right">$268k</td><td class="px-4 py-3 text-right">$301k</td><td class="px-4 py-3 text-right">$289k</td><td class="px-4 py-3 text-right text-teal-700">$315k</td><td class="px-4 py-3 text-right font-medium">$858k</td><td class="px-4 py-3 text-right">7,204</td><td class="px-4 py-3 text-right">$119.10</td><td class="px-4 py-3 text-right text-emerald-700">+6.1%</td>
        </tr>
        <tr>
          <th scope="row" class="sticky left-0 z-10 border-r border-teal-900/10 bg-white px-4 py-3 text-left font-medium">Benelux</th>
          <td class="px-4 py-3 text-right">$335k</td><td class="px-4 py-3 text-right">$322k</td><td class="px-4 py-3 text-right">$318k</td><td class="px-4 py-3 text-right text-teal-700">$340k</td><td class="px-4 py-3 text-right font-medium">$975k</td><td class="px-4 py-3 text-right">6,650</td><td class="px-4 py-3 text-right">$146.60</td><td class="px-4 py-3 text-right text-red-700">-2.8%</td>
        </tr>
        <tr>
          <th scope="row" class="sticky left-0 z-10 border-r border-teal-900/10 bg-white px-4 py-3 text-left font-medium">Baltics</th>
          <td class="px-4 py-3 text-right">$94k</td><td class="px-4 py-3 text-right">$118k</td><td class="px-4 py-3 text-right">$131k</td><td class="px-4 py-3 text-right text-teal-700">$150k</td><td class="px-4 py-3 text-right font-medium">$343k</td><td class="px-4 py-3 text-right">3,018</td><td class="px-4 py-3 text-right">$113.70</td><td class="px-4 py-3 text-right text-emerald-700">+21.9%</td>
        </tr>
        <tr>
          <th scope="row" class="sticky left-0 z-10 border-r border-teal-900/10 bg-white px-4 py-3 text-left font-medium">Alpine</th>
          <td class="px-4 py-3 text-right">$377k</td><td class="px-4 py-3 text-right">$362k</td><td class="px-4 py-3 text-right">$401k</td><td class="px-4 py-3 text-right text-teal-700">$425k</td><td class="px-4 py-3 text-right font-medium">$1.14M</td><td class="px-4 py-3 text-right">6,129</td><td class="px-4 py-3 text-right">$186.00</td><td class="px-4 py-3 text-right text-emerald-700">+3.3%</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Sticky header in a capped scroll area

  • sticky top-0
  • max-h-80
  • overflow-auto
  • bg-neutral-800
  • role="region"

Scroll inside the board.

The wrapper caps its height with max-h-80 and scrolls with overflow-auto, and the thead takes sticky top-0 z-10 plus a solid background so rows never show through it. The header is borderless on purpose: collapsed cell borders belong to the table and do not travel with a sticky cell, which is where most sticky-header bugs come from. Times use font-mono tabular-nums for a departure-board look.

View code
<div class="w-full max-w-2xl">
  <h2 id="ttb-dep-title" class="mb-3 text-base font-semibold text-amber-100">Departures, Terminal 2</h2>
  <div class="max-h-80 overflow-auto rounded-xl bg-neutral-900 ring-1 ring-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300" role="region" aria-labelledby="ttb-dep-title" tabindex="0">
    <table class="w-full text-left text-sm text-neutral-100">
      <thead class="sticky top-0 z-10 bg-neutral-800 text-xs font-semibold uppercase tracking-wider text-amber-300">
        <tr>
          <th scope="col" class="px-4 py-3">Time</th>
          <th scope="col" class="px-4 py-3">Destination</th>
          <th scope="col" class="hidden px-4 py-3 sm:table-cell">Flight</th>
          <th scope="col" class="px-4 py-3 text-right sm:text-left">Gate</th>
          <th scope="col" class="px-4 py-3">Status</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-white/5">
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">06:10</td><td class="px-4 py-3">Reykjavik</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 214</td><td class="px-4 py-3 text-right font-mono sm:text-left">B4</td><td class="px-4 py-3 text-neutral-400">Departed</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">06:35</td><td class="px-4 py-3">Lisbon</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 1180</td><td class="px-4 py-3 text-right font-mono sm:text-left">C11</td><td class="px-4 py-3 text-neutral-400">Departed</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">07:05</td><td class="px-4 py-3">Tallinn</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">OR 402</td><td class="px-4 py-3 text-right font-mono sm:text-left">A2</td><td class="px-4 py-3 font-medium text-amber-300">Boarding</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">07:20</td><td class="px-4 py-3">Edinburgh</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 77</td><td class="px-4 py-3 text-right font-mono sm:text-left">B9</td><td class="px-4 py-3 font-medium text-amber-300">Boarding</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">07:45</td><td class="px-4 py-3">Vienna</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">OR 615</td><td class="px-4 py-3 text-right font-mono sm:text-left">C3</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">08:00</td><td class="px-4 py-3">Porto</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 1192</td><td class="px-4 py-3 text-right font-mono sm:text-left">C14</td><td class="px-4 py-3 font-medium text-rose-300">Delayed 08:40</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">08:15</td><td class="px-4 py-3">Krakow</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">OR 230</td><td class="px-4 py-3 text-right font-mono sm:text-left">A7</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">08:30</td><td class="px-4 py-3">Bergen</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 309</td><td class="px-4 py-3 text-right font-mono sm:text-left">B1</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">08:50</td><td class="px-4 py-3">Ljubljana</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">OR 518</td><td class="px-4 py-3 text-right font-mono sm:text-left">A5</td><td class="px-4 py-3 font-medium text-rose-300">Cancelled</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">09:05</td><td class="px-4 py-3">Dublin</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 88</td><td class="px-4 py-3 text-right font-mono sm:text-left">B6</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">09:25</td><td class="px-4 py-3">Seville</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 1201</td><td class="px-4 py-3 text-right font-mono sm:text-left">C9</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">09:40</td><td class="px-4 py-3">Gdansk</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">OR 244</td><td class="px-4 py-3 text-right font-mono sm:text-left">A1</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">10:10</td><td class="px-4 py-3">Tromso</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 331</td><td class="px-4 py-3 text-right font-mono sm:text-left">B3</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
        <tr><td class="px-4 py-3 font-mono tabular-nums text-amber-100">10:35</td><td class="px-4 py-3">Valencia</td><td class="hidden px-4 py-3 font-mono text-neutral-400 sm:table-cell">KV 1215</td><td class="px-4 py-3 text-right font-mono sm:text-left">C2</td><td class="px-4 py-3 text-emerald-300">On time</td></tr>
      </tbody>
    </table>
  </div>
</div>

Rows that stack into cards on phones

  • max-md:block
  • max-md:*:flex
  • before:content-[attr(data-label)]
  • data-label

Below md the table, tbody and rows switch to max-md:block, each row becomes a bordered card, and the *: child variant turns every cell into a flex line. The label comes from a data-label attribute printed with max-md:*:before:content-[attr(data-label)], while the thead is hidden. Current browsers keep table semantics for block display, but hidden headers and generated labels still deserve a screen reader check.

View code
<div class="w-full max-w-3xl">
  <h2 id="ttb-tickets-title" class="mb-3 text-base font-semibold text-rose-950">Open support tickets</h2>
  <table class="w-full text-left text-sm text-stone-700 max-md:block md:overflow-hidden md:rounded-xl md:bg-white md:ring-1 md:ring-rose-950/10" aria-labelledby="ttb-tickets-title">
    <thead class="bg-rose-50 text-xs font-semibold text-rose-900 max-md:hidden">
      <tr>
        <th scope="col" class="px-4 py-3">Ticket</th>
        <th scope="col" class="px-4 py-3">Subject</th>
        <th scope="col" class="px-4 py-3">Customer</th>
        <th scope="col" class="px-4 py-3">Priority</th>
        <th scope="col" class="px-4 py-3 text-right">Updated</th>
      </tr>
    </thead>
    <tbody class="max-md:block max-md:space-y-3 md:divide-y md:divide-rose-950/10">
      <tr class="max-md:block max-md:rounded-xl max-md:bg-white max-md:p-2 max-md:ring-1 max-md:ring-rose-950/10 max-md:*:flex max-md:*:justify-between max-md:*:gap-4 max-md:*:px-2 max-md:*:py-1.5 max-md:*:text-right max-md:*:before:text-left max-md:*:before:font-sans max-md:*:before:font-medium max-md:*:before:text-stone-600 max-md:*:before:content-[attr(data-label)]">
        <td data-label="Ticket" class="px-4 py-3 font-mono font-medium text-rose-900">TK-2291</td>
        <td data-label="Subject" class="px-4 py-3 text-stone-900">Invoice PDF shows the wrong VAT number</td>
        <td data-label="Customer" class="px-4 py-3">Marlow Ceramics</td>
        <td data-label="Priority" class="px-4 py-3"><span class="rounded-md bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-800">Urgent</span></td>
        <td data-label="Updated" class="px-4 py-3 md:text-right">12 min ago</td>
      </tr>
      <tr class="max-md:block max-md:rounded-xl max-md:bg-white max-md:p-2 max-md:ring-1 max-md:ring-rose-950/10 max-md:*:flex max-md:*:justify-between max-md:*:gap-4 max-md:*:px-2 max-md:*:py-1.5 max-md:*:text-right max-md:*:before:text-left max-md:*:before:font-sans max-md:*:before:font-medium max-md:*:before:text-stone-600 max-md:*:before:content-[attr(data-label)]">
        <td data-label="Ticket" class="px-4 py-3 font-mono font-medium text-rose-900">TK-2288</td>
        <td data-label="Subject" class="px-4 py-3 text-stone-900">Cannot add a second admin</td>
        <td data-label="Customer" class="px-4 py-3">Quayside Rowing Club</td>
        <td data-label="Priority" class="px-4 py-3"><span class="rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">High</span></td>
        <td data-label="Updated" class="px-4 py-3 md:text-right">1 h ago</td>
      </tr>
      <tr class="max-md:block max-md:rounded-xl max-md:bg-white max-md:p-2 max-md:ring-1 max-md:ring-rose-950/10 max-md:*:flex max-md:*:justify-between max-md:*:gap-4 max-md:*:px-2 max-md:*:py-1.5 max-md:*:text-right max-md:*:before:text-left max-md:*:before:font-sans max-md:*:before:font-medium max-md:*:before:text-stone-600 max-md:*:before:content-[attr(data-label)]">
        <td data-label="Ticket" class="px-4 py-3 font-mono font-medium text-rose-900">TK-2280</td>
        <td data-label="Subject" class="px-4 py-3 text-stone-900">Export to CSV drops accents</td>
        <td data-label="Customer" class="px-4 py-3">Atelier Noemi</td>
        <td data-label="Priority" class="px-4 py-3"><span class="rounded-md bg-stone-100 px-2 py-0.5 text-xs font-semibold text-stone-700">Normal</span></td>
        <td data-label="Updated" class="px-4 py-3 md:text-right">Yesterday</td>
      </tr>
    </tbody>
  </table>
</div>

Columns driven by container queries

  • @container
  • @md:table-cell
  • @lg:table-cell
  • resize-x

Drag the bottom-right corner of the box to resize it.

The wrapper is an @container, and low-priority columns use hidden @md:table-cell or hidden @lg:table-cell, so they react to the width of the box instead of the viewport. That is what you want when the same table lives in a sidebar and in a full-width page. resize-x lets you drag the box narrower, and a tiny ResizeObserver prints its current width.

View code
<div class="w-full max-w-3xl">
  <div class="mb-2 flex items-center justify-between gap-3 text-sm">
    <h2 class="font-semibold text-cyan-950">Shipments this week</h2>
    <p class="font-mono text-xs text-cyan-800" aria-hidden="true">box <span id="ttb-cq-width">auto</span></p>
  </div>
  <div id="ttb-cq-box" class="@container w-full min-w-64 max-w-full resize-x overflow-auto rounded-xl bg-white ring-1 ring-cyan-900/15">
    <table class="w-full text-left text-sm text-slate-700">
      <caption class="sr-only">Shipments with tracking number, destination, carrier, weight, arrival date and status</caption>
      <thead class="bg-cyan-50 text-xs font-semibold text-cyan-900">
        <tr>
          <th scope="col" class="px-3 py-2.5">Tracking</th>
          <th scope="col" class="px-3 py-2.5">Destination</th>
          <th scope="col" class="hidden px-3 py-2.5 @lg:table-cell">Carrier</th>
          <th scope="col" class="hidden px-3 py-2.5 text-right @md:table-cell">Weight</th>
          <th scope="col" class="hidden px-3 py-2.5 @sm:table-cell">ETA</th>
          <th scope="col" class="px-3 py-2.5 text-right">Status</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-slate-200">
        <tr><td class="px-3 py-2.5 font-mono text-xs text-slate-900">SH-88217</td><td class="px-3 py-2.5">Ghent</td><td class="hidden px-3 py-2.5 @lg:table-cell">Coastline Freight</td><td class="hidden px-3 py-2.5 text-right tabular-nums @md:table-cell">12.4 kg</td><td class="hidden px-3 py-2.5 @sm:table-cell">Oct 30</td><td class="px-3 py-2.5 text-right font-medium text-cyan-800">In transit</td></tr>
        <tr><td class="px-3 py-2.5 font-mono text-xs text-slate-900">SH-88209</td><td class="px-3 py-2.5">Turin</td><td class="hidden px-3 py-2.5 @lg:table-cell">Alpenline</td><td class="hidden px-3 py-2.5 text-right tabular-nums @md:table-cell">3.1 kg</td><td class="hidden px-3 py-2.5 @sm:table-cell">Oct 29</td><td class="px-3 py-2.5 text-right font-medium text-emerald-700">Delivered</td></tr>
        <tr><td class="px-3 py-2.5 font-mono text-xs text-slate-900">SH-88196</td><td class="px-3 py-2.5">Aarhus</td><td class="hidden px-3 py-2.5 @lg:table-cell">Coastline Freight</td><td class="hidden px-3 py-2.5 text-right tabular-nums @md:table-cell">27.0 kg</td><td class="hidden px-3 py-2.5 @sm:table-cell">Nov 2</td><td class="px-3 py-2.5 text-right font-medium text-amber-700">Customs</td></tr>
        <tr><td class="px-3 py-2.5 font-mono text-xs text-slate-900">SH-88190</td><td class="px-3 py-2.5">Brno</td><td class="hidden px-3 py-2.5 @lg:table-cell">Vltava Post</td><td class="hidden px-3 py-2.5 text-right tabular-nums @md:table-cell">0.8 kg</td><td class="hidden px-3 py-2.5 @sm:table-cell">Oct 31</td><td class="px-3 py-2.5 text-right font-medium text-cyan-800">In transit</td></tr>
      </tbody>
    </table>
  </div>
</div>

Sorting, filtering, selection and paging

Tailwind ships no JavaScript, so these tables add a few lines of vanilla JS while the visual states come from aria-sort, aria-expanded, aria-current and has-checked: variants.

Sortable columns with aria-sort

  • aria-sort
  • group-aria-[sort=ascending]:
  • aria-live
  • tabular-nums

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

Each sortable header holds a real button, and the th carries aria-sort only while it is the sorted column, as the APG sortable table pattern describes. The arrow icons are pure Tailwind: the th is a group, and group-aria-[sort=ascending]:block or group-aria-[sort=descending]:block shows the right one. The JS sorts the tbody rows by a data-value on each cell and announces the new order in a polite live region.

View code
<div class="w-full max-w-2xl rounded-xl bg-white shadow-sm ring-1 ring-indigo-950/10">
  <table id="ttb-sort-table" class="w-full text-left text-sm text-slate-700">
    <caption class="px-4 pt-4 pb-3 text-left text-base font-semibold text-slate-900">Studio inventory <span class="sr-only">, column headers with buttons are sortable</span></caption>
    <thead class="border-y border-slate-200 bg-slate-50 text-xs font-semibold text-slate-700">
      <tr>
        <th scope="col" class="group px-2 py-1" aria-sort="ascending">
          <button type="button" class="ttb-sort-btn inline-flex min-h-10 items-center gap-1 rounded-md px-2 hover:bg-indigo-50 hover:text-indigo-800 focus-visible:outline-2 focus-visible:outline-indigo-600" data-type="text" data-label="Product">Product
            <svg viewBox="0 0 16 16" class="size-4 stroke-current fill-none stroke-[1.5] text-slate-400 group-aria-[sort=ascending]:hidden group-aria-[sort=descending]:hidden" aria-hidden="true"><path d="m5 6 3-3 3 3M5 10l3 3 3-3"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=ascending]:block" aria-hidden="true"><path d="m4 10 4-4 4 4"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=descending]:block" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
          </button>
        </th>
        <th scope="col" class="hidden px-4 py-1 sm:table-cell">SKU</th>
        <th scope="col" class="group px-2 py-1 text-right">
          <button type="button" class="ttb-sort-btn inline-flex min-h-10 items-center gap-1 rounded-md px-2 hover:bg-indigo-50 hover:text-indigo-800 focus-visible:outline-2 focus-visible:outline-indigo-600" data-type="number" data-label="Stock">Stock
            <svg viewBox="0 0 16 16" class="size-4 stroke-current fill-none stroke-[1.5] text-slate-400 group-aria-[sort=ascending]:hidden group-aria-[sort=descending]:hidden" aria-hidden="true"><path d="m5 6 3-3 3 3M5 10l3 3 3-3"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=ascending]:block" aria-hidden="true"><path d="m4 10 4-4 4 4"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=descending]:block" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
          </button>
        </th>
        <th scope="col" class="group px-2 py-1 text-right">
          <button type="button" class="ttb-sort-btn inline-flex min-h-10 items-center gap-1 rounded-md px-2 hover:bg-indigo-50 hover:text-indigo-800 focus-visible:outline-2 focus-visible:outline-indigo-600" data-type="number" data-label="Price">Price
            <svg viewBox="0 0 16 16" class="size-4 stroke-current fill-none stroke-[1.5] text-slate-400 group-aria-[sort=ascending]:hidden group-aria-[sort=descending]:hidden" aria-hidden="true"><path d="m5 6 3-3 3 3M5 10l3 3 3-3"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=ascending]:block" aria-hidden="true"><path d="m4 10 4-4 4 4"/></svg>
            <svg viewBox="0 0 16 16" class="hidden size-4 stroke-current fill-none stroke-2 text-indigo-600 group-aria-[sort=descending]:block" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
          </button>
        </th>
      </tr>
    </thead>
    <tbody class="divide-y divide-slate-100 tabular-nums">
      <tr><td class="px-4 py-3 font-medium text-slate-900">Ash desk lamp</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">LMP-031</td><td class="px-4 py-3 text-right" data-value="42">42</td><td class="px-4 py-3 text-right" data-value="89">$89.00</td></tr>
      <tr><td class="px-4 py-3 font-medium text-slate-900">Felt pin board</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">PNB-118</td><td class="px-4 py-3 text-right" data-value="7"><span class="font-semibold text-amber-700">7</span></td><td class="px-4 py-3 text-right" data-value="34.5">$34.50</td></tr>
      <tr><td class="px-4 py-3 font-medium text-slate-900">Linen notebook</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">NTB-004</td><td class="px-4 py-3 text-right" data-value="310">310</td><td class="px-4 py-3 text-right" data-value="18">$18.00</td></tr>
      <tr><td class="px-4 py-3 font-medium text-slate-900">Oak monitor riser</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">RSR-207</td><td class="px-4 py-3 text-right" data-value="0"><span class="font-semibold text-red-700">0</span></td><td class="px-4 py-3 text-right" data-value="129">$129.00</td></tr>
      <tr><td class="px-4 py-3 font-medium text-slate-900">Stoneware mug</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">MUG-090</td><td class="px-4 py-3 text-right" data-value="96">96</td><td class="px-4 py-3 text-right" data-value="22">$22.00</td></tr>
      <tr><td class="px-4 py-3 font-medium text-slate-900">Wool desk mat</td><td class="hidden px-4 py-3 font-mono text-xs text-slate-600 sm:table-cell">MAT-152</td><td class="px-4 py-3 text-right" data-value="58">58</td><td class="px-4 py-3 text-right" data-value="64">$64.00</td></tr>
    </tbody>
  </table>
  <p id="ttb-sort-status" class="sr-only" aria-live="polite"></p>
</div>

Searchable table with a live count

  • type="search"
  • appearance-none
  • aria-live
  • hidden attribute

Type a name or pick a team.

A labelled search field and a native select filter the rows by toggling the hidden attribute, which Preflight turns into display: none. The count sits in a polite live region, so screen reader users hear "Showing 3 of 10 people" without losing focus, and an empty-state row appears when nothing matches. The select is restyled with appearance-none and an inline SVG chevron, no forms plugin needed.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-emerald-950/10 sm:p-5">
  <h2 class="text-base font-semibold text-emerald-950">Team directory</h2>
  <div class="mt-3 flex flex-col gap-3 sm:flex-row">
    <div class="relative flex-1">
      <label for="ttb-filter-q" class="sr-only">Search by name</label>
      <svg viewBox="0 0 20 20" class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 fill-none stroke-slate-500 stroke-2" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="m14 14 4 4" stroke-linecap="round"/></svg>
      <input id="ttb-filter-q" type="search" placeholder="Search by name" autocomplete="off" class="h-11 w-full rounded-lg border border-slate-300 bg-white pr-3 pl-9 text-sm text-slate-900 placeholder:text-slate-500 focus:border-emerald-600 focus:outline-2 focus:outline-emerald-600/30">
    </div>
    <div class="relative sm:w-44">
      <label for="ttb-filter-team" class="sr-only">Team</label>
      <select id="ttb-filter-team" class="h-11 w-full appearance-none rounded-lg border border-slate-300 bg-white pr-9 pl-3 text-sm text-slate-900 focus:border-emerald-600 focus:outline-2 focus:outline-emerald-600/30">
        <option value="">All teams</option>
        <option value="Design">Design</option>
        <option value="Engineering">Engineering</option>
        <option value="Support">Support</option>
      </select>
      <svg viewBox="0 0 20 20" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 fill-none stroke-slate-500 stroke-2" aria-hidden="true"><path d="m6 8 4 4 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </div>
  </div>
  <p id="ttb-filter-count" class="mt-3 text-sm text-slate-600" aria-live="polite">Showing 10 of 10 people</p>
  <div class="mt-2 overflow-x-auto">
    <table class="w-full text-left text-sm text-slate-700">
      <caption class="sr-only">Team members with team and office</caption>
      <thead class="border-b border-slate-200 text-xs font-semibold text-slate-600">
        <tr><th scope="col" class="py-2 pr-3">Name</th><th scope="col" class="px-3 py-2">Team</th><th scope="col" class="py-2 pl-3">Office</th></tr>
      </thead>
      <tbody class="divide-y divide-slate-100">
        <tr class="ttb-filter-row" data-team="Design"><td class="py-2.5 pr-3 font-medium text-slate-900">Priya Natarajan</td><td class="px-3 py-2.5">Design</td><td class="py-2.5 pl-3">Lisbon</td></tr>
        <tr class="ttb-filter-row" data-team="Engineering"><td class="py-2.5 pr-3 font-medium text-slate-900">Jonas Lindqvist</td><td class="px-3 py-2.5">Engineering</td><td class="py-2.5 pl-3">Remote</td></tr>
        <tr class="ttb-filter-row" data-team="Support"><td class="py-2.5 pr-3 font-medium text-slate-900">Amara Okafor</td><td class="px-3 py-2.5">Support</td><td class="py-2.5 pl-3">Leeds</td></tr>
        <tr class="ttb-filter-row" data-team="Engineering"><td class="py-2.5 pr-3 font-medium text-slate-900">Diego Ferreira</td><td class="px-3 py-2.5">Engineering</td><td class="py-2.5 pl-3">Lisbon</td></tr>
        <tr class="ttb-filter-row" data-team="Design"><td class="py-2.5 pr-3 font-medium text-slate-900">Hana Kobayashi</td><td class="px-3 py-2.5">Design</td><td class="py-2.5 pl-3">Remote</td></tr>
        <tr class="ttb-filter-row" data-team="Support"><td class="py-2.5 pr-3 font-medium text-slate-900">Olu Adebayo</td><td class="px-3 py-2.5">Support</td><td class="py-2.5 pl-3">Leeds</td></tr>
        <tr class="ttb-filter-row" data-team="Engineering"><td class="py-2.5 pr-3 font-medium text-slate-900">Sofia Marchetti</td><td class="px-3 py-2.5">Engineering</td><td class="py-2.5 pl-3">Remote</td></tr>
        <tr class="ttb-filter-row" data-team="Design"><td class="py-2.5 pr-3 font-medium text-slate-900">Noah Brennan</td><td class="px-3 py-2.5">Design</td><td class="py-2.5 pl-3">Leeds</td></tr>
        <tr class="ttb-filter-row" data-team="Engineering"><td class="py-2.5 pr-3 font-medium text-slate-900">Mira Petrova</td><td class="px-3 py-2.5">Engineering</td><td class="py-2.5 pl-3">Lisbon</td></tr>
        <tr class="ttb-filter-row" data-team="Support"><td class="py-2.5 pr-3 font-medium text-slate-900">Kwame Asante</td><td class="px-3 py-2.5">Support</td><td class="py-2.5 pl-3">Remote</td></tr>
        <tr id="ttb-filter-empty" hidden><td colspan="3" class="py-8 text-center text-slate-600">No people match. Try another name or team.</td></tr>
      </tbody>
    </table>
  </div>
</div>

Bulk selection with an indeterminate checkbox

  • has-checked:
  • accent-blue-600
  • indeterminate
  • disabled:

Tick a few rows, then try the header checkbox and Archive.

Row highlighting needs no JS: each tr has has-checked:bg-blue-50, so the row tints as soon as its checkbox is ticked. The header checkbox is native and themed with accent-blue-600, and the JS sets its indeterminate property when only some rows are selected, which no HTML attribute can do. The toolbar count and the disabled: state of Archive follow the selection, and Undo brings archived rows back.

View code
<div class="w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
  <div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-4 py-3">
    <h2 class="text-base font-semibold text-slate-900">Project files</h2>
    <div class="flex items-center gap-2">
      <p id="ttb-bulk-count" class="text-sm text-slate-600">No files selected</p>
      <button id="ttb-bulk-archive" type="button" class="min-h-9 rounded-lg bg-blue-600 px-3 text-sm font-semibold text-white hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-500" disabled>Archive</button>
    </div>
  </div>
  <table class="w-full text-left text-sm text-slate-700">
    <caption class="sr-only">Project files with size and last change, select rows to archive them</caption>
    <thead class="bg-slate-50 text-xs font-semibold text-slate-600">
      <tr>
        <th scope="col" class="w-12 px-4 py-2.5"><input id="ttb-bulk-all" type="checkbox" class="size-4 align-middle accent-blue-600" aria-label="Select all files"></th>
        <th scope="col" class="py-2.5 pr-3">Name</th>
        <th scope="col" class="hidden px-3 py-2.5 text-right sm:table-cell">Size</th>
        <th scope="col" class="py-2.5 pr-4 pl-3 text-right">Changed</th>
      </tr>
    </thead>
    <tbody id="ttb-bulk-body" class="divide-y divide-slate-100">
      <tr class="has-checked:bg-blue-50"><td class="px-4 py-3"><input type="checkbox" class="ttb-bulk-row size-4 align-middle accent-blue-600" aria-label="Select brand-guidelines.pdf"></td><td class="py-3 pr-3 font-medium text-slate-900 break-all">brand-guidelines.pdf</td><td class="hidden px-3 py-3 text-right tabular-nums sm:table-cell">4.2 MB</td><td class="py-3 pr-4 pl-3 text-right whitespace-nowrap">Oct 27</td></tr>
      <tr class="has-checked:bg-blue-50"><td class="px-4 py-3"><input type="checkbox" class="ttb-bulk-row size-4 align-middle accent-blue-600" aria-label="Select homepage-v3.fig"></td><td class="py-3 pr-3 font-medium text-slate-900 break-all">homepage-v3.fig</td><td class="hidden px-3 py-3 text-right tabular-nums sm:table-cell">18.9 MB</td><td class="py-3 pr-4 pl-3 text-right whitespace-nowrap">Oct 26</td></tr>
      <tr class="has-checked:bg-blue-50"><td class="px-4 py-3"><input type="checkbox" class="ttb-bulk-row size-4 align-middle accent-blue-600" aria-label="Select photo-shoot-selects.zip"></td><td class="py-3 pr-3 font-medium text-slate-900 break-all">photo-shoot-selects.zip</td><td class="hidden px-3 py-3 text-right tabular-nums sm:table-cell">212 MB</td><td class="py-3 pr-4 pl-3 text-right whitespace-nowrap">Oct 22</td></tr>
      <tr class="has-checked:bg-blue-50"><td class="px-4 py-3"><input type="checkbox" class="ttb-bulk-row size-4 align-middle accent-blue-600" aria-label="Select copy-deck.docx"></td><td class="py-3 pr-3 font-medium text-slate-900 break-all">copy-deck.docx</td><td class="hidden px-3 py-3 text-right tabular-nums sm:table-cell">96 KB</td><td class="py-3 pr-4 pl-3 text-right whitespace-nowrap">Oct 19</td></tr>
      <tr class="has-checked:bg-blue-50"><td class="px-4 py-3"><input type="checkbox" class="ttb-bulk-row size-4 align-middle accent-blue-600" aria-label="Select sitemap-draft.csv"></td><td class="py-3 pr-3 font-medium text-slate-900 break-all">sitemap-draft.csv</td><td class="hidden px-3 py-3 text-right tabular-nums sm:table-cell">12 KB</td><td class="py-3 pr-4 pl-3 text-right whitespace-nowrap">Oct 14</td></tr>
    </tbody>
  </table>
  <div class="flex min-h-12 items-center justify-between gap-3 border-t border-slate-200 px-4 py-2 text-sm">
    <p id="ttb-bulk-status" class="text-slate-600" role="status"></p>
    <button id="ttb-bulk-undo" type="button" class="min-h-9 rounded-lg px-3 font-semibold text-blue-700 hover:bg-blue-50 focus-visible:outline-2 focus-visible:outline-blue-600" hidden>Undo</button>
  </div>
</div>

Expandable rows with order details

  • aria-expanded
  • group-aria-expanded:rotate-90
  • colspan
  • motion-safe:transition

Use the arrow buttons to open an order.

Every summary row has a disclosure button with aria-expanded and aria-controls pointing at the detail row below it, which spans the full table with colspan. The chevron rotates through group-aria-expanded:rotate-90 on the button, and the transition sits inside motion-safe:. The JS only flips aria-expanded and the hidden attribute on the detail row.

View code
<div class="w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-orange-950/10">
  <table class="w-full text-left text-sm text-stone-700">
    <caption class="border-b border-stone-200 px-4 py-3 text-left text-base font-semibold text-stone-900">Wholesale orders</caption>
    <thead class="bg-orange-50 text-xs font-semibold text-orange-900">
      <tr><th scope="col" class="px-4 py-2.5">Order</th><th scope="col" class="px-3 py-2.5">Status</th><th scope="col" class="px-4 py-2.5 text-right">Total</th></tr>
    </thead>
    <tbody class="divide-y divide-stone-200">
      <tr>
        <td class="px-2 py-2">
          <button type="button" class="ttb-exp-btn group flex min-h-11 items-center gap-2 rounded-md px-2 text-left hover:bg-orange-50 focus-visible:outline-2 focus-visible:outline-orange-600" aria-expanded="true" aria-controls="ttb-exp-1">
            <svg viewBox="0 0 20 20" class="size-4 shrink-0 fill-none stroke-orange-700 stroke-2 group-aria-expanded:rotate-90 motion-safe:transition-transform" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
            <span><span class="block font-medium text-stone-900">WO-7741</span><span class="block text-xs text-stone-600">Pinecrest Grocers</span></span>
          </button>
        </td>
        <td class="px-3 py-2"><span class="rounded-md bg-sky-100 px-2 py-0.5 text-xs font-semibold text-sky-800">Packing</span></td>
        <td class="px-4 py-2 text-right font-medium text-stone-900 tabular-nums">$1,284.00</td>
      </tr>
      <tr id="ttb-exp-1" class="bg-stone-50">
        <td colspan="3" class="px-4 py-4 sm:pl-12">
          <div class="grid gap-4 text-sm sm:grid-cols-[1fr_auto]">
            <ul class="space-y-1.5 tabular-nums">
              <li class="flex justify-between gap-4"><span>Wildflower honey, 24 jars</span><span class="text-stone-900">$432.00</span></li>
              <li class="flex justify-between gap-4"><span>Oat crackers, 40 boxes</span><span class="text-stone-900">$360.00</span></li>
              <li class="flex justify-between gap-4"><span>Cold brew concentrate, 12 cases</span><span class="text-stone-900">$492.00</span></li>
            </ul>
            <div class="text-xs text-stone-600 sm:border-l sm:border-stone-200 sm:pl-4">
              <p class="font-semibold text-stone-800">Ship to</p>
              <p>14 Harbour Lane, Unit 3</p>
              <p>Brightwater, BW4 2LP</p>
            </div>
          </div>
        </td>
      </tr>
      <tr>
        <td class="px-2 py-2">
          <button type="button" class="ttb-exp-btn group flex min-h-11 items-center gap-2 rounded-md px-2 text-left hover:bg-orange-50 focus-visible:outline-2 focus-visible:outline-orange-600" aria-expanded="false" aria-controls="ttb-exp-2">
            <svg viewBox="0 0 20 20" class="size-4 shrink-0 fill-none stroke-orange-700 stroke-2 group-aria-expanded:rotate-90 motion-safe:transition-transform" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
            <span><span class="block font-medium text-stone-900">WO-7738</span><span class="block text-xs text-stone-600">Hollis Street Deli</span></span>
          </button>
        </td>
        <td class="px-3 py-2"><span class="rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-800">Shipped</span></td>
        <td class="px-4 py-2 text-right font-medium text-stone-900 tabular-nums">$642.50</td>
      </tr>
      <tr id="ttb-exp-2" class="bg-stone-50" hidden>
        <td colspan="3" class="px-4 py-4 sm:pl-12">
          <div class="grid gap-4 text-sm sm:grid-cols-[1fr_auto]">
            <ul class="space-y-1.5 tabular-nums">
              <li class="flex justify-between gap-4"><span>Sourdough starter kits, 15</span><span class="text-stone-900">$247.50</span></li>
              <li class="flex justify-between gap-4"><span>Smoked sea salt, 30 tins</span><span class="text-stone-900">$395.00</span></li>
            </ul>
            <div class="text-xs text-stone-600 sm:border-l sm:border-stone-200 sm:pl-4">
              <p class="font-semibold text-stone-800">Tracking</p>
              <p class="font-mono">CF 4410 9921 07</p>
            </div>
          </div>
        </td>
      </tr>
      <tr>
        <td class="px-2 py-2">
          <button type="button" class="ttb-exp-btn group flex min-h-11 items-center gap-2 rounded-md px-2 text-left hover:bg-orange-50 focus-visible:outline-2 focus-visible:outline-orange-600" aria-expanded="false" aria-controls="ttb-exp-3">
            <svg viewBox="0 0 20 20" class="size-4 shrink-0 fill-none stroke-orange-700 stroke-2 group-aria-expanded:rotate-90 motion-safe:transition-transform" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
            <span><span class="block font-medium text-stone-900">WO-7735</span><span class="block text-xs text-stone-600">The Copper Kettle</span></span>
          </button>
        </td>
        <td class="px-3 py-2"><span class="rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">On hold</span></td>
        <td class="px-4 py-2 text-right font-medium text-stone-900 tabular-nums">$2,010.00</td>
      </tr>
      <tr id="ttb-exp-3" class="bg-stone-50" hidden>
        <td colspan="3" class="px-4 py-4 sm:pl-12">
          <p class="text-sm text-stone-700">Waiting for the customer to confirm a new delivery date. Reserved stock is held until Nov 4.</p>
        </td>
      </tr>
    </tbody>
  </table>
</div>

Pagination footer

  • aria-current="page"
  • aria-[current=page]:
  • disabled:
  • nav aria-label

Use the page buttons or Next and Previous.

The rows are all in the markup, and the JS shows five at a time with the hidden attribute. The page buttons sit in a nav with aria-label, the current one carries aria-current="page" and is styled with aria-[current=page]:bg-slate-900, and Previous or Next switch to disabled: styles at the ends. The range text under the table updates with each page.

View code
<div class="w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
  <table class="w-full text-left text-sm text-slate-700">
    <caption class="border-b border-slate-200 px-4 py-3 text-left text-base font-semibold text-slate-900">Card transactions, October</caption>
    <thead class="bg-slate-50 text-xs font-semibold text-slate-600">
      <tr><th scope="col" class="px-4 py-2.5">Date</th><th scope="col" class="px-3 py-2.5">Merchant</th><th scope="col" class="px-4 py-2.5 text-right">Amount</th></tr>
    </thead>
    <tbody class="divide-y divide-slate-100 tabular-nums">
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 28</td><td class="px-3 py-3 text-slate-900">Juniper Print Shop</td><td class="px-4 py-3 text-right">-$64.20</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 27</td><td class="px-3 py-3 text-slate-900">Client payment, Larkfield</td><td class="px-4 py-3 text-right text-emerald-700">+$2,400.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 26</td><td class="px-3 py-3 text-slate-900">Hosting renewal</td><td class="px-4 py-3 text-right">-$29.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 24</td><td class="px-3 py-3 text-slate-900">Corner Bean Cafe</td><td class="px-4 py-3 text-right">-$11.40</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 23</td><td class="px-3 py-3 text-slate-900">Font licence, desktop</td><td class="px-4 py-3 text-right">-$120.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 21</td><td class="px-3 py-3 text-slate-900">Rail ticket, return</td><td class="px-4 py-3 text-right">-$48.60</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 19</td><td class="px-3 py-3 text-slate-900">Client payment, Okapi Labs</td><td class="px-4 py-3 text-right text-emerald-700">+$1,150.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 17</td><td class="px-3 py-3 text-slate-900">Coworking day pass</td><td class="px-4 py-3 text-right">-$25.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 15</td><td class="px-3 py-3 text-slate-900">Stock photo credits</td><td class="px-4 py-3 text-right">-$39.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 12</td><td class="px-3 py-3 text-slate-900">Juniper Print Shop</td><td class="px-4 py-3 text-right">-$18.75</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 10</td><td class="px-3 py-3 text-slate-900">Design tool seat</td><td class="px-4 py-3 text-right">-$15.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 8</td><td class="px-3 py-3 text-slate-900">Client payment, Tern and Co</td><td class="px-4 py-3 text-right text-emerald-700">+$860.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 6</td><td class="px-3 py-3 text-slate-900">Corner Bean Cafe</td><td class="px-4 py-3 text-right">-$7.90</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 4</td><td class="px-3 py-3 text-slate-900">External drive</td><td class="px-4 py-3 text-right">-$89.99</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 3</td><td class="px-3 py-3 text-slate-900">Domain renewal</td><td class="px-4 py-3 text-right">-$14.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 2</td><td class="px-3 py-3 text-slate-900">Bookkeeping app</td><td class="px-4 py-3 text-right">-$22.00</td></tr>
      <tr class="ttb-page-row"><td class="px-4 py-3 whitespace-nowrap">Oct 1</td><td class="px-3 py-3 text-slate-900">Studio rent</td><td class="px-4 py-3 text-right">-$650.00</td></tr>
    </tbody>
  </table>
  <div class="flex flex-col items-center gap-3 border-t border-slate-200 px-4 py-3 sm:flex-row sm:justify-between">
    <p id="ttb-page-info" class="text-sm text-slate-600">Showing 1 to 17 of 17</p>
    <nav aria-label="Transactions pages" class="flex items-center gap-1">
      <button id="ttb-page-prev" type="button" class="inline-flex size-10 items-center justify-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-slate-900 disabled:pointer-events-none disabled:text-slate-300" aria-label="Previous page"><svg viewBox="0 0 20 20" class="size-5 fill-none stroke-current stroke-2" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
      <button type="button" class="ttb-page-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white" data-page="1"><span class="sr-only">Page </span>1</button>
      <button type="button" class="ttb-page-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white" data-page="2"><span class="sr-only">Page </span>2</button>
      <button type="button" class="ttb-page-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white" data-page="3"><span class="sr-only">Page </span>3</button>
      <button type="button" class="ttb-page-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 aria-[current=page]:bg-slate-900 aria-[current=page]:text-white" data-page="4"><span class="sr-only">Page </span>4</button>
      <button id="ttb-page-next" type="button" class="inline-flex size-10 items-center justify-center rounded-lg text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-slate-900 disabled:pointer-events-none disabled:text-slate-300" aria-label="Next page"><svg viewBox="0 0 20 20" class="size-5 fill-none stroke-current stroke-2" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
    </nav>
  </div>
</div>

Dark mode, rich cells and custom tokens

A table that switches with the dark: variant, a team list with initials avatars and a popover actions menu, and a heat map built from border-separate and @theme color tokens.

Dark mode table with a toggle

  • dark:
  • @custom-variant dark
  • dark:odd:bg-gray-900/50
  • aria-pressed

Press the theme button.

Every color has a dark: pair, using the stripe classes from the Tailwind docs: odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950. The page defines @custom-variant dark (&:where(.dark, .dark *)), so the button only toggles a dark class on the card wrapper and sets aria-pressed. The sun and moon icons swap with dark:hidden and dark:block.

View code
<div id="ttb-dm" class="w-full max-w-2xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-200 sm:p-5 dark:bg-gray-900 dark:ring-white/10">
  <div class="flex items-center justify-between gap-3">
    <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Service uptime, October</h2>
    <button id="ttb-dm-toggle" type="button" class="inline-flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-gray-700 ring-1 ring-gray-300 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 dark:text-gray-200 dark:ring-white/15 dark:hover:bg-white/10 dark:focus-visible:outline-sky-400" aria-pressed="false">
      <svg viewBox="0 0 20 20" class="size-4 fill-none stroke-current stroke-2 dark:hidden" aria-hidden="true"><path d="M17 12.5A7 7 0 0 1 7.5 3a7 7 0 1 0 9.5 9.5Z" stroke-linejoin="round"/></svg>
      <svg viewBox="0 0 20 20" class="hidden size-4 fill-none stroke-current stroke-2 dark:block" aria-hidden="true"><circle cx="10" cy="10" r="3.5"/><path d="M10 1.5v2M10 16.5v2M1.5 10h2M16.5 10h2M4 4l1.4 1.4M14.6 14.6 16 16M4 16l1.4-1.4M14.6 5.4 16 4" stroke-linecap="round"/></svg>
      Dark mode
    </button>
  </div>
  <div class="mt-4 overflow-x-auto rounded-lg ring-1 ring-gray-200 dark:ring-gray-700">
    <table class="w-full text-left text-sm text-gray-700 dark:text-gray-300">
      <caption class="sr-only">Uptime and incidents per service for October</caption>
      <thead class="bg-gray-50 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-300">
        <tr><th scope="col" class="px-4 py-2.5">Service</th><th scope="col" class="hidden px-4 py-2.5 sm:table-cell">Region</th><th scope="col" class="px-4 py-2.5 text-right">Uptime</th><th scope="col" class="px-4 py-2.5 text-right">Incidents</th></tr>
      </thead>
      <tbody class="divide-y divide-gray-200 tabular-nums dark:divide-gray-700">
        <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950"><td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">API gateway</td><td class="hidden px-4 py-3 sm:table-cell">eu-north</td><td class="px-4 py-3 text-right text-emerald-700 dark:text-emerald-400">99.99%</td><td class="px-4 py-3 text-right">0</td></tr>
        <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950"><td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">Checkout</td><td class="hidden px-4 py-3 sm:table-cell">eu-west</td><td class="px-4 py-3 text-right text-emerald-700 dark:text-emerald-400">99.95%</td><td class="px-4 py-3 text-right">1</td></tr>
        <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950"><td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">Search</td><td class="hidden px-4 py-3 sm:table-cell">eu-west</td><td class="px-4 py-3 text-right text-amber-700 dark:text-amber-400">99.71%</td><td class="px-4 py-3 text-right">3</td></tr>
        <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950"><td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">Image CDN</td><td class="hidden px-4 py-3 sm:table-cell">global</td><td class="px-4 py-3 text-right text-emerald-700 dark:text-emerald-400">100.00%</td><td class="px-4 py-3 text-right">0</td></tr>
        <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900/50 dark:even:bg-gray-950"><td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">Email jobs</td><td class="hidden px-4 py-3 sm:table-cell">us-east</td><td class="px-4 py-3 text-right text-red-700 dark:text-red-400">98.62%</td><td class="px-4 py-3 text-right">5</td></tr>
      </tbody>
    </table>
  </div>
</div>

Team table with avatars and an actions menu

  • popover
  • popovertarget
  • fill-*
  • size-10
  • inset-auto

Open the menu at the end of a row.

Avatars are inline SVG circles with initials, colored with fill-fuchsia-100 and fill-fuchsia-800 style utilities. The row menu uses the Popover API, so it renders in the top layer and is never clipped by the overflow-x-auto wrapper, and light dismiss and Escape come free. A short toggle listener places the popover under its button with inset-auto and inline top and left values, and flips it above when there is no room.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white shadow-sm ring-1 ring-fuchsia-950/10">
  <div class="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
    <h2 class="text-base font-semibold text-zinc-900">Workspace members</h2>
    <p id="ttb-people-status" class="text-right text-xs text-zinc-600" role="status"></p>
  </div>
  <div class="overflow-x-auto">
    <table class="w-full text-left text-sm text-zinc-700">
      <caption class="sr-only">Workspace members with role and last activity</caption>
      <thead class="border-y border-zinc-200 bg-zinc-50 text-xs font-semibold text-zinc-600">
        <tr><th scope="col" class="px-4 py-2.5">Member</th><th scope="col" class="hidden px-3 py-2.5 sm:table-cell">Role</th><th scope="col" class="hidden px-3 py-2.5 md:table-cell">Last active</th><th scope="col" class="px-4 py-2.5 text-right"><span class="sr-only">Actions</span></th></tr>
      </thead>
      <tbody class="divide-y divide-zinc-100">
        <tr>
          <td class="px-4 py-3"><div class="flex items-center gap-3"><svg viewBox="0 0 40 40" class="size-10 shrink-0" aria-hidden="true"><circle cx="20" cy="20" r="20" class="fill-fuchsia-100"/><text x="20" y="25" text-anchor="middle" class="fill-fuchsia-800 text-sm font-semibold">MQ</text></svg><div class="min-w-0"><p class="font-medium text-zinc-900">Mara Quinn</p><p class="truncate text-xs text-zinc-600">mara@example.com</p></div></div></td>
          <td class="hidden px-3 py-3 sm:table-cell"><span class="rounded-md bg-fuchsia-50 px-2 py-0.5 text-xs font-semibold text-fuchsia-800">Owner</span></td>
          <td class="hidden px-3 py-3 md:table-cell">Online now</td>
          <td class="px-4 py-3 text-right"><button type="button" class="ttb-menu-btn inline-flex size-10 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-fuchsia-600" popovertarget="ttb-act-1" aria-label="Actions for Mara Quinn"><svg viewBox="0 0 20 20" class="size-5 fill-current" aria-hidden="true"><circle cx="10" cy="4.5" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="15.5" r="1.5"/></svg></button>
            <div id="ttb-act-1" popover class="ttb-menu inset-auto m-0 w-48 rounded-xl bg-white p-1 text-left text-sm text-zinc-700 shadow-lg ring-1 ring-zinc-900/10" data-person="Mara Quinn">
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">View profile</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">Change role</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 text-red-700 hover:bg-red-50 focus-visible:bg-red-50 focus-visible:outline-none">Remove from workspace</button>
            </div>
          </td>
        </tr>
        <tr>
          <td class="px-4 py-3"><div class="flex items-center gap-3"><svg viewBox="0 0 40 40" class="size-10 shrink-0" aria-hidden="true"><circle cx="20" cy="20" r="20" class="fill-teal-100"/><text x="20" y="25" text-anchor="middle" class="fill-teal-800 text-sm font-semibold">EB</text></svg><div class="min-w-0"><p class="font-medium text-zinc-900">Eitan Barak</p><p class="truncate text-xs text-zinc-600">eitan@example.com</p></div></div></td>
          <td class="hidden px-3 py-3 sm:table-cell"><span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-semibold text-zinc-700">Editor</span></td>
          <td class="hidden px-3 py-3 md:table-cell">2 hours ago</td>
          <td class="px-4 py-3 text-right"><button type="button" class="ttb-menu-btn inline-flex size-10 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-fuchsia-600" popovertarget="ttb-act-2" aria-label="Actions for Eitan Barak"><svg viewBox="0 0 20 20" class="size-5 fill-current" aria-hidden="true"><circle cx="10" cy="4.5" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="15.5" r="1.5"/></svg></button>
            <div id="ttb-act-2" popover class="ttb-menu inset-auto m-0 w-48 rounded-xl bg-white p-1 text-left text-sm text-zinc-700 shadow-lg ring-1 ring-zinc-900/10" data-person="Eitan Barak">
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">View profile</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">Change role</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 text-red-700 hover:bg-red-50 focus-visible:bg-red-50 focus-visible:outline-none">Remove from workspace</button>
            </div>
          </td>
        </tr>
        <tr>
          <td class="px-4 py-3"><div class="flex items-center gap-3"><svg viewBox="0 0 40 40" class="size-10 shrink-0" aria-hidden="true"><circle cx="20" cy="20" r="20" class="fill-amber-100"/><text x="20" y="25" text-anchor="middle" class="fill-amber-900 text-sm font-semibold">SO</text></svg><div class="min-w-0"><p class="font-medium text-zinc-900">Sade Okonkwo</p><p class="truncate text-xs text-zinc-600">sade@example.com</p></div></div></td>
          <td class="hidden px-3 py-3 sm:table-cell"><span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-semibold text-zinc-700">Editor</span></td>
          <td class="hidden px-3 py-3 md:table-cell">Yesterday</td>
          <td class="px-4 py-3 text-right"><button type="button" class="ttb-menu-btn inline-flex size-10 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-fuchsia-600" popovertarget="ttb-act-3" aria-label="Actions for Sade Okonkwo"><svg viewBox="0 0 20 20" class="size-5 fill-current" aria-hidden="true"><circle cx="10" cy="4.5" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="15.5" r="1.5"/></svg></button>
            <div id="ttb-act-3" popover class="ttb-menu inset-auto m-0 w-48 rounded-xl bg-white p-1 text-left text-sm text-zinc-700 shadow-lg ring-1 ring-zinc-900/10" data-person="Sade Okonkwo">
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">View profile</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">Change role</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 text-red-700 hover:bg-red-50 focus-visible:bg-red-50 focus-visible:outline-none">Remove from workspace</button>
            </div>
          </td>
        </tr>
        <tr>
          <td class="px-4 py-3"><div class="flex items-center gap-3"><svg viewBox="0 0 40 40" class="size-10 shrink-0" aria-hidden="true"><circle cx="20" cy="20" r="20" class="fill-sky-100"/><text x="20" y="25" text-anchor="middle" class="fill-sky-800 text-sm font-semibold">LV</text></svg><div class="min-w-0"><p class="font-medium text-zinc-900">Luca Valente</p><p class="truncate text-xs text-zinc-600">luca@example.com</p></div></div></td>
          <td class="hidden px-3 py-3 sm:table-cell"><span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-semibold text-zinc-700">Viewer</span></td>
          <td class="hidden px-3 py-3 md:table-cell">Oct 21</td>
          <td class="px-4 py-3 text-right"><button type="button" class="ttb-menu-btn inline-flex size-10 items-center justify-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-fuchsia-600" popovertarget="ttb-act-4" aria-label="Actions for Luca Valente"><svg viewBox="0 0 20 20" class="size-5 fill-current" aria-hidden="true"><circle cx="10" cy="4.5" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="15.5" r="1.5"/></svg></button>
            <div id="ttb-act-4" popover class="ttb-menu inset-auto m-0 w-48 rounded-xl bg-white p-1 text-left text-sm text-zinc-700 shadow-lg ring-1 ring-zinc-900/10" data-person="Luca Valente">
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">View profile</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 hover:bg-zinc-100 focus-visible:bg-zinc-100 focus-visible:outline-none">Change role</button>
              <button type="button" class="ttb-menu-item flex min-h-10 w-full items-center rounded-lg px-3 text-red-700 hover:bg-red-50 focus-visible:bg-red-50 focus-visible:outline-none">Remove from workspace</button>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Heat map with border-separate and @theme tokens

  • @theme
  • border-separate
  • border-spacing-1
  • bg-ttb-heat-*
  • rounded-md

Five @theme tokens such as --color-ttb-heat-3 become real utilities (bg-ttb-heat-3), so the scale lives in one place. border-separate with border-spacing-1 opens gaps between cells, which also lets rounded-md work on each td, something collapsed borders do not allow. Every cell prints its number, so the color is a second signal rather than the only one, and the darker steps switch to white text for contrast.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-emerald-950/10 sm:p-6">
  <div class="overflow-x-auto">
    <table class="w-full border-separate border-spacing-1 text-center text-xs tabular-nums sm:border-spacing-1.5 sm:text-sm">
      <caption class="pb-3 text-left text-base font-semibold text-emerald-950">Class bookings by time slot <span class="block text-sm font-normal text-stone-600">Harbourside Yoga, last four weeks</span></caption>
      <thead class="text-stone-600">
        <tr><td></td><th scope="col" class="pb-1 font-medium">7am</th><th scope="col" class="pb-1 font-medium">10am</th><th scope="col" class="pb-1 font-medium">1pm</th><th scope="col" class="pb-1 font-medium">5pm</th><th scope="col" class="pb-1 font-medium">8pm</th></tr>
      </thead>
      <tbody class="font-semibold">
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Mon</th><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">38</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">9</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">21</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">54</td><td class="rounded-md bg-ttb-heat-4 py-2.5 text-white">71</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Tue</th><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">27</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">12</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">19</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">58</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">62</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Wed</th><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">41</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">8</td><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">33</td><td class="rounded-md bg-ttb-heat-4 py-2.5 text-white">69</td><td class="rounded-md bg-ttb-heat-4 py-2.5 text-white">74</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Thu</th><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">24</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">16</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">22</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">57</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">60</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Fri</th><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">35</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">18</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">11</td><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">44</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">26</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Sat</th><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">20</td><td class="rounded-md bg-ttb-heat-4 py-2.5 text-white">78</td><td class="rounded-md bg-ttb-heat-3 py-2.5 text-white">52</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">23</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">7</td></tr>
        <tr><th scope="row" class="pr-1 text-left font-medium text-stone-600">Sun</th><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">10</td><td class="rounded-md bg-ttb-heat-4 py-2.5 text-white">81</td><td class="rounded-md bg-ttb-heat-2 py-2.5 text-ttb-ink">39</td><td class="rounded-md bg-ttb-heat-1 py-2.5 text-ttb-ink">17</td><td class="rounded-md bg-ttb-heat-0 py-2.5 text-ttb-ink">5</td></tr>
      </tbody>
    </table>
  </div>
  <div class="mt-4 flex flex-wrap items-center gap-2 text-xs text-stone-600" aria-hidden="true">
    <span>Fewer</span>
    <span class="size-4 rounded bg-ttb-heat-0 ring-1 ring-emerald-950/10"></span><span class="size-4 rounded bg-ttb-heat-1"></span><span class="size-4 rounded bg-ttb-heat-2"></span><span class="size-4 rounded bg-ttb-heat-3"></span><span class="size-4 rounded bg-ttb-heat-4"></span>
    <span>More bookings</span>
  </div>
</div>

What is a Tailwind table?

Nothing exotic. It’s a regular HTML data table where layout, borders, spacing and hover states come from utility classes instead of a custom stylesheet.

Since Tailwind is a utility-first CSS framework, each class sets one property, and a table gets built by stacking classes on plain elements. The usual elements all apply. The table is the container, caption gives it a visible title, thead, tbody and tfoot split the header, body and summary rows, and tr, th and td handle rows and cells.

Four utilities are specific to tables: border-collapse, border-spacing, table-layout and caption-side (Tailwind CSS v4.3 documentation). Everything else, from divide-y to the odd: and hover: variants, is a general utility that also works on rows and cells.

Bootstrap works the other way around. In Bootstrap tables, one table class switches on a default look. Tailwind makes you pick each piece yourself, which costs a few more classes but removes the overrides.

How to build a basic Tailwind table

The recipe is a wrapper div, w-full and a text size on the table, the same padding on every th and td, and divide-y with an explicit color on tbody. It behaves the same in Tailwind v3 and v4.

  1. Create a wrapper div with overflow-x-auto, rounded-lg, border and border-gray-200.
  2. Add the table with w-full, border-collapse, text-left and text-sm.
  3. Add a thead with bg-gray-50, and give each th scope=”col”, px-4, py-3 and font-semibold.
  4. Add a tbody with divide-y and divide-gray-200 so each row gets a separator.
  5. Give each td px-4 and py-3, matching the th padding so columns line up.

The browser default for border-collapse is separate (MDN), but Tailwind’s Preflight already sets border-collapse: collapse on table elements, so the class in step 2 mainly makes the intent explicit. It starts to matter if you disable Preflight or switch the table to border-separate elsewhere. Padding goes on th and td, because rows do not take padding in the default table display.

Skipping the wrapper is the mistake I see most on a first build. A wide table then pushes the whole page sideways instead of scrolling inside its own box.

Typing forty rows by hand gets old fast. An HTML table generator can produce the row and column skeleton, and the classes from the steps go on top.

Table layout utilities: table-auto, table-fixed and border-collapse

table-auto and table-fixed decide how columns get sized, while border-collapse and border-separate decide how borders are drawn.

UtilityCSS outputUse whenLimit
table-autotable-layout: autoColumns should fit their contentWidths shift as cell text changes
table-fixedtable-layout: fixedWidths are set once and must holdNeeds a table width; long content can overflow
border-collapseborder-collapse: collapseSingle-line borders and row dividersborder-radius does not apply to the table
border-separateborder-collapse: separateGaps between cells, rounded table cornersGap is set with border-spacing-*

Auto and fixed layout

table-auto sizes each column from its cell content. table-fixed ignores content and reads column widths from the first row, according to Tailwind’s table-layout documentation.

table-fixed has no effect on a table whose width is auto. MDN documents that fixed layout needs an explicit table width, and falls back to the automatic algorithm without one.

Tailwind’s own table-fixed example sets the class alone, so add w-full or another width class when you copy it.

Set widths on the th cells of the first row, such as w-1/2 or w-32. Columns without a width split the remaining space evenly.

Fixed layout renders faster and holds its shape, but later cells can outgrow their column (MDN). I’d use table-fixed for dashboards with predictable columns and keep table-auto for content tables where text length varies.

Both utilities take breakpoint prefixes, so table-auto md:table-fixed changes the algorithm at the md breakpoint.

Collapsed and separate borders

With border-collapse, adjacent cells share one border, so a row divider draws as a single line. border-separate gives every cell its own border, and the gap between them comes from border-spacing-*.

border-spacing-2, border-spacing-x-3 and border-spacing-y-1 only act on a table with separate borders (Tailwind border-spacing documentation). The x and y versions let you tune horizontal and vertical gaps independently.

If you already know how to style tables in plain CSS, nothing here is new. The utilities are the same table-layout, border-collapse and border-spacing properties with shorter names.

Styling rows, cells and borders

Stripes come from odd: and even: variants on tr, hover states from hover:, dividers from divide-y on tbody, and dark mode from dark: color pairs. Tailwind v4 also changed the default border color, which breaks tables that relied on it.

Striped and hoverable rows

Put the stripe classes on each tr: odd:bg-white even:bg-gray-50. Tailwind’s documentation shows this exact pattern for table rows, paired with dark:odd:bg-gray-900/50 and dark:even:bg-gray-950 for dark mode.

Row highlighting is a standard hover effect, written as hover:bg-gray-100 on the tr.

In v4, the hover variant only applies when the primary input device supports hover, through the @media (hover: hover) query (Tailwind v4 upgrade guide). On touch screens the row highlight never fires, so treat it as a bonus and rely on the stripes for readability.

Borders, corners and the v4 color default

Tailwind v4 changed the default color of border-* and divide-* utilities from gray-200 to currentColor.

A v3 table with a bare divide-y now draws text-colored lines after the upgrade. Name the color on every border and divide class, or add the upgrade guide’s base-layer rule that sets border-color to gray-200 for all elements.

Check the browser floor before upgrading. v4 targets Safari 16.4, Chrome 111 and Firefox 128 (Tailwind upgrade guide), so projects that must support older browsers stay on v3.4.

Rounded corners need a decision. MDN states that border-radius does not apply to table elements when border-collapse is collapse.

Keep border-collapse and clip the corners with rounded-lg on a wrapper, as the wrapper in the build steps does. The other route is switching the table to border-separate with border-spacing-0.

Dark mode color pairs

The dark: variant follows prefers-color-scheme by default. To drive it from a class or data attribute, override it with a @custom-variant dark rule (Tailwind dark mode documentation).

  • text-gray-900 with dark:text-gray-100 for body text
  • divide-gray-200 with dark:divide-gray-700 for the row lines
  • bg-gray-50 with dark:bg-gray-800 on the header
  • odd:bg-white even:bg-gray-50 plus dark:odd:bg-gray-900/50 and dark:even:bg-gray-950 for stripes

Check each stripe against its text color. A dark stripe at 50% opacity shifts with whatever sits behind the table.

How to make a Tailwind table responsive

A wide table on a phone can scroll sideways or lose a few low-priority columns. A third option, restacking each row as a card, replaces the table layout entirely and carries the most accessibility risk. Scrolling leaves everything intact.

Choose by how much of the table a reader needs at once. This is a responsive design decision more than a styling one.

Horizontal scroll wrapper

A wrapper with overflow-x-auto only scrolls once the table is wider than it is. Add whitespace-nowrap to the cells and min-w-full to the table so columns keep their natural width instead of squeezing.

HyperUI’s table snippets use exactly that pairing inside the wrapper.

Every column and row relationship stays intact, and native table semantics are untouched. The weak spot is discoverability, since nothing tells the reader more columns exist. Leave a column cut off at the right edge, or add a shadow, as the cue.

Hiding columns by breakpoint

Put hidden on a cell and md:table-cell next to it to bring the column back at wider screens. The md variant starts at 48rem, because Tailwind’s breakpoints are min-width media queries (Tailwind variants reference).

The remaining columns get room and nothing scrolls sideways. The price is that hidden data is gone on small screens, so fold it into a visible cell or a detail view.

Apply the classes to the th and to every td in that column together. Hiding only the data cells breaks the column alignment.

Stacked cards on small screens

Stacking sets block on tr and td, hides the thead below md, and prints each label from a data-label attribute with before:content-[attr(data-label)].

On a phone it reads like a list, and nobody needs a sideways gesture. But every row repeats its labels, wide numeric comparisons across rows get hard to scan, and a hidden thead with generated labels can leave screen reader users without column context.

Setting display to block, grid or flex on table elements made browsers drop the native table semantics in Adrian Roselli’s 2018 testing. His own reading of the specs is that dropping them was a browser bug rather than intended behavior. In his October 2023 update, tables with display properties applied work in Chromium (since Chrome 80), Gecko and WebKit (since Safari 17), and only display: contents is still problematic. Test the final markup with a screen reader anyway, because a hidden thead and generated labels are not covered by that fix.

Sticky headers in a Tailwind table

Put sticky and top-0 on the thead, give it a background, and cap the wrapper height so the table scrolls inside its own box. Each of those parts fails in its own way, which is why sticky headers have a reputation for breaking.

SymptomCauseFix
Header scrolls awayWrapper has no height limitAdd max-h-96 and overflow-auto to the wrapper
Rows show through the headerthead has no backgroundAdd bg-white and dark:bg-gray-900 to the thead
Cell borders vanish on the headerCollapsed borders belong to the table, not the cellDraw the line on the wrapper, or use border-separate with border-spacing-0
A 1px strip of the next row shows above the headerThe shared border sits outside the sticky boxOffset top by the border width (-top-px) and clip with overflow-hidden

HyperUI’s sticky-heading snippet covers the first two rows: a max-height wrapper, sticky top-0 on the thead, and an explicit background with a dark variant.

The last two rows trace back to the same cause, and both show up in public bug trackers. Mozilla’s tracker lists borders disappearing on sticky th cells in a collapsed-border table (Bug 1727594, reported on Firefox 91 and still open and unconfirmed as of October 2026). A Mozilla engineer noted that all browsers behave the same on the test case, so no change is planned unless the spec changes. Bootstrap’s table-sticky-headers branch has a commit titled “close the sticky header 1px gap” that closed the strip with a top offset, because top-0 cannot cover a border the cells share.

daisyUI skips the debugging. Its table-pin-rows modifier pins the thead and tfoot rows, table-pin-cols pins the th columns, and the docs example sits in a fixed-height wrapper.

I prefer a borderless sticky header on a tinted background, since it avoids the whole class of border bugs in the table above.

Making a Tailwind table accessible

Give the table a caption, use th for header cells, keep the scroll wrapper reachable by keyboard, and avoid display overrides on table elements. Tailwind sets none of this for you, because the utilities style markup and the semantics come from the elements you write.

A caption element can be shown with caption-top or hidden with sr-only. Header cells should be th, with scope added when headers run along both rows and columns. Empty header cells get sr-only text, as Flowbite’s markup does for its “Edit” column.

W3C WAI describes the caption as the primary way assistive technology users identify a table in table-browsing mode.

WCAG technique H63 says th elements alone are enough for simple tables with headers in the first row or column. Scope starts to matter when a second header axis appears.

Captions and header markup are the core of accessible tables. The summary attribute is obsolete in the HTML Living Standard (WCAG technique H73), so describe a long table in the caption or a nearby paragraph.

From Chrome 132, scroll containers are keyboard focusable by default when they hold no focusable children (Chrome for Developers, which first announced the change for Chrome 130).

Rows with links or buttons change that. The browser skips the wrapper and focuses the first control, and Chrome’s guidance is to set tabindex=”0″ when the container itself should take focus. Before Chrome 132, the wrapper needed tabindex=”0″ to be reachable at all.

Pair it with focus-visible:outline-2 on the wrapper, so only keyboard users see the focus ring.

Stacked-card layouts set block on table elements, which stripped table semantics in Roselli’s 2018 testing. ARIA roles such as role=”cell” were the workaround, but he found they can be problematic once header cells are hidden. Current browsers keep table semantics for most display values, and display: contents is the one he still flags, so the remaining risk is a hidden thead and generated labels. If screen reader users matter for the page, scroll the table instead.

Which fits: a plain table, a component kit or a headless library?

A plain Tailwind table suits static or lightly interactive data. A component kit gets you a finished look fast, and a headless library earns its place once sorting, filtering or pagination state is needed. The markup stays the same across all of them. What changes is who writes the logic.

Choosing the approach

OptionTable logicMarkup and stylesFits when
Plain Tailwind tableNoneYou write bothStatic or lightly interactive data
Component kit (HyperUI, Flowbite, daisyUI)None, or the kit’s JavaScriptPrebuilt classesA finished look in minutes
Headless library (TanStack Table)Sorting, filtering, pagination, selectionYou write bothTables users can change
Grid library (AG Grid)FullReady-made, theme-basedLarge grids with little design control

Start plain. A kit’s styling is ordinary utility classes you could write yourself, and what you take on is its JavaScript. Flowbite marks its search and checkbox-selection examples as requiring Flowbite JS.

A Tailwind UI kit still earns its place when a team wants tables, buttons and forms from one consistent source.

Component sources compared

Ranked for a static Tailwind table, by how little you need to adopt:

  1. HyperUI ships copy-paste snippets under the MIT license, with sticky-heading and sticky-first-column variants and plain markup with no scripts.
  2. daisyUI gives you a table class with modifiers for zebra stripes, pinned rows and columns, and five sizes from table-xs to table-xl.
  3. Flowbite has striped, hover, caption, overflow and sortable-header examples, with a Pro version above the free tier.
  4. Preline UI comes under the MIT license plus the Preline UI Fair Use License, with a DataTable plugin and a paid Pro tier for premium blocks.
  5. Material Tailwind offers MIT-licensed HTML and React packages with striped-row and striped-column table examples.

Tailwind Plus is the exception. The official paid kit, formerly Tailwind UI, closed to new sign-ups when Tailwind Labs joined Shopify on September 9, 2026, and existing customers keep access (Tailwind Labs announcement).

Roundups that still present it as purchasable are out of date, and tailwindcss.com/plus resolved to a login form when checked on October 29, 2026. Tailwind CSS itself stays MIT-licensed.

Headless logic with TanStack Table

TanStack Table supplies sorting, filtering, pagination and row selection as state and functions, and it renders no markup or styles. The Tailwind classes stay yours.

The shadcn/ui route has no data-table component to install, because every data table behaves differently. The guide wraps TanStack Table around a Table component and styles it with utilities such as overflow-hidden rounded-md border.

Pagination controls are separate Tailwind pagination markup, driven by the page state the library exposes.

Versions moved recently. TanStack Table v9 left beta with 9.0.0 on August 4, 2026 (npm registry), and shadcn/ui’s data-table guide builds on v9, while many tutorials still show v8 code. Pin the version you install and read the matching docs.

TanStack’s own overview names AG Grid as the pre-built alternative, where you style the result from the outside through props and theme APIs. A headless library costs setup instead, since it ships no markup, styles or themes.

Add TanStack Table when users can change what the table shows. Keep a plain table when the page only displays the data.

Tailwind table FAQ

How do I right-align numbers in a Tailwind table?

Put text-right on the th and on every td in that column, and add tabular-nums so each digit takes the same width. Tailwind’s font-variant-numeric documentation pairs text-right with slashed-zero and tabular-nums in a subtotal, tax and total list.

Use text-center the same way when a column should be centered.

Can I use a Tailwind table in React or Vue?

Yes. The markup and classes are identical, with React writing className where Vue and plain HTML write class.

For sorting and pagination, TanStack Table supports both frameworks, and Flowbite publishes integration guides for each.

Can div elements replace table tags in a Tailwind table?

Visually, yes. Preline’s table docs show table display utilities on div elements for a table-like layout without semantic table tags.

A div gains no table semantics from its display value, according to Adrian Roselli, so keep real table elements for data.

Does the Tailwind Typography prose class style tables?

Yes. The plugin’s prose class styles table, thead, tr, th and td, and modifiers such as prose-table:{utility} and prose-th:{utility} adjust them.

Wrap a hand-styled table in not-prose, the plugin’s sandbox class, so prose styles do not compete with your utilities.

How do I make the first column sticky in a Tailwind table?

Give the first cell of every row sticky, left-0 and a background, inside an overflow-x-auto wrapper. HyperUI’s snippet sets *:first:sticky, *:first:left-0 and *:first:bg-white on each tr.

Its dark variant adds dark:*:first:bg-gray-900, so scrolling cells do not show through the pinned column.

Reusing Tailwind Table Styles Across Pages

A Tailwind table belongs in one component or template partial once a second file needs it. Tailwind’s documentation names that as the best strategy for styles shared across multiple files.

Work down this order and stop at the first step that removes the duplication.

  • Render rows in a loop, so the class list exists once
  • Edit repeated cells together with multi-cursor editing inside one file
  • Extract a component or partial for use across files

A custom table class in the components layer falls short. Tailwind recommends partials for anything beyond a single HTML element, and a table spans table, thead, tbody, th and td.

The trade-off is flexibility. Expose props instead of letting call sites add classes, because a clash between two utilities resolves by stylesheet order, not class attribute order.

Position verified against the Tailwind CSS v4.3 documentation on October 29, 2026.