EmpireUI
Get Pro
← Blog8 min read#tailwind#table#data

Tailwind Table Design: Striped, Hoverable, Sortable Data Tables

Build production-ready Tailwind CSS tables with striped rows, hover states, and sortable columns — no third-party library needed. Full code examples inside.

Developer working on a data table UI on a laptop screen

Why Tailwind Tables Are Harder Than They Look

Tables are one of those things that look solved until you actually sit down to build one. You drop a <table> into your JSX, throw on a few Tailwind classes, and suddenly you're fighting browser default styles, collapsed borders, and cells that refuse to align. It's a rite of passage.

The core problem is that HTML tables were designed in 1996 for tabular data — not for the design systems we're building in 2026. Tailwind resets most browser defaults, but <table> still carries baggage: border-collapse behavior, default cell padding of 0, and that awkward gap between adjacent cells that only disappears once you understand border-collapse: collapse.

Honestly, once you understand three or four utility combinations, the rest flows naturally. This guide covers the patterns you'll actually reach for: striped rows with odd: and even: variants, hover states that don't murder your accessibility score, and a sortable header pattern you can wire up with about 20 lines of React state. No libraries. No dependencies. Just Tailwind.

Worth noting: everything here targets Tailwind CSS v3.4+. The odd: and even: pseudo-class variants shipped earlier, but some of the ring utilities we use for focus states in the sort headers were tightened up in v3.

The Base Table Structure

Start with a wrapper div. Always. You need overflow-x-auto on the container so the table scrolls horizontally on mobile instead of overflowing the viewport — skip this and your layout breaks on anything narrower than 768px.

<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-700">
  <table className="w-full border-collapse text-sm">
    <thead className="bg-zinc-50 dark:bg-zinc-800">
      <tr>
        <th className="px-4 py-3 text-left font-semibold text-zinc-700 dark:text-zinc-300">
          Name
        </th>
        <th className="px-4 py-3 text-left font-semibold text-zinc-700 dark:text-zinc-300">
          Status
        </th>
        <th className="px-4 py-3 text-right font-semibold text-zinc-700 dark:text-zinc-300">
          Revenue
        </th>
      </tr>
    </thead>
    <tbody>
      {/* rows go here */}
    </tbody>
  </table>
</div>

border-collapse on the <table> itself is non-negotiable — without it you get the double-border effect where each cell draws its own border and they stack. w-full makes it fill the container. text-sm keeps data dense without feeling cramped; 14px is the sweet spot for most admin dashboards.

The px-4 py-3 combo gives you 16px horizontal and 12px vertical padding on each cell. That's a reasonable default, but if you're building something information-dense like an analytics table, drop it to px-3 py-2. The header font-semibold at weight 600 gives enough visual separation from body rows without needing a background color to do all the work.

One more thing — add border-b border-zinc-200 dark:border-zinc-700 to your <thead> row. That single bottom border on the header section is what separates it from data rows visually. A lot of beginner implementations miss this and then try to compensate with heavier background colors.

Striped Rows with Tailwind's odd: and even: Variants

Stripe patterns exist for one reason: they help your eye track horizontally across wide rows. On a table with 8+ columns, losing your place mid-row is genuinely annoying. The odd: and even: pseudo-class variants in Tailwind make this completely trivial.

<tbody>
  {rows.map((row, i) => (
    <tr
      key={row.id}
      className="
        border-b border-zinc-100 dark:border-zinc-800
        odd:bg-white even:bg-zinc-50
        dark:odd:bg-zinc-900 dark:even:bg-zinc-800/50
      "
    >
      <td className="px-4 py-3 text-zinc-900 dark:text-zinc-100">{row.name}</td>
      <td className="px-4 py-3">
        <span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
          {row.status}
        </span>
      </td>
      <td className="px-4 py-3 text-right tabular-nums text-zinc-700 dark:text-zinc-300">
        {row.revenue}
      </td>
    </tr>
  ))}
</tbody>

The tabular-nums class on numeric cells is something most guides skip. It applies font-variant-numeric: tabular-nums, which forces all digits to the same width so numbers align vertically in a column. Without it, 1,234 and 10,234 end up misaligned because the 1 is narrower than the 10.

For the stripe contrast ratio, you want the difference between odd and even backgrounds to be subtle — maybe 3-4% luminosity difference. zinc-50 vs white in light mode hits that target. Going heavier than that (like zinc-100 vs white) reads as two distinct surface levels rather than a unified table, which fights the UI instead of supporting it.

In practice, I don't stripe every table I build. Short tables — under 8 rows — read fine without stripes and look cleaner. Stripes start earning their keep at 10+ rows or when you have 5+ columns.

Hover States That Actually Feel Good

Row hover is the interaction that separates a dead table from one that feels alive. The trap is using a hover color that's too similar to your stripe colors — then hovering on an even row looks the same as resting on an odd row and the effect is invisible.

<tr
  className="
    border-b border-zinc-100 dark:border-zinc-800
    odd:bg-white even:bg-zinc-50
    dark:odd:bg-zinc-900 dark:even:bg-zinc-800/50
    hover:bg-blue-50 dark:hover:bg-blue-900/20
    cursor-pointer transition-colors duration-100
  "
  onClick={() => handleRowClick(row)}
>

The transition-colors duration-100 is important. 100ms feels instant but prevents the jarring pop of a color change with no transition. Go longer than 150ms and the table starts to feel laggy when you're scanning rows quickly.

If you're making rows clickable, add cursor-pointer and a visible focus style. Keyboard navigation matters. Wrap each row in a <tr tabIndex={0}> and add focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 — that's your keyboard user's only signal that the row is interactive. Look, skipping focus styles is a real accessibility failure, not just a theoretical one.

Quick aside: for selected rows (checkbox tables or multi-select), use bg-blue-50 dark:bg-blue-900/20 as the selected state with a ring-1 ring-inset ring-blue-500 on the row. It's distinct from hover without requiring a separate style layer.

Sortable Column Headers

Sortable headers are where most tutorials hand you off to a library. You don't need one. Here's a self-contained React pattern that handles ascending, descending, and unsorted states with clean visual feedback.

import { useState } from 'react'
import { ChevronUpIcon, ChevronDownIcon, ChevronsUpDownIcon } from 'lucide-react'

type SortDir = 'asc' | 'desc' | null

function SortableHeader({
  label,
  field,
  sortField,
  sortDir,
  onSort,
}: {
  label: string
  field: string
  sortField: string | null
  sortDir: SortDir
  onSort: (field: string) => void
}) {
  const isActive = sortField === field
  const Icon = isActive
    ? sortDir === 'asc'
      ? ChevronUpIcon
      : ChevronDownIcon
    : ChevronsUpDownIcon

  return (
    <th
      className="px-4 py-3 text-left"
      aria-sort={
        isActive ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'
      }
    >
      <button
        onClick={() => onSort(field)}
        className="
          flex items-center gap-1.5 font-semibold text-zinc-700
          hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-white
          focus-visible:outline-none focus-visible:ring-2
          focus-visible:ring-blue-500 rounded
        "
      >
        {label}
        <Icon className="h-3.5 w-3.5 opacity-60" />
      </button>
    </th>
  )
}

The aria-sort attribute on <th> is the accessible way to communicate sort direction to screen readers. ascending, descending, and none are the valid values. Most implementations forget this and fail a basic ARIA audit.

For the sort logic itself, you toggle through three states: null → 'asc' → 'desc' → null. On the third click you clear the sort and return to original order. Keep original order in a ref so you can restore it without re-fetching:

const [sortField, setSortField] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>(null)
const originalOrder = useRef(data)

function handleSort(field: string) {
  if (sortField !== field) {
    setSortField(field)
    setSortDir('asc')
  } else if (sortDir === 'asc') {
    setSortDir('desc')
  } else {
    setSortField(null)
    setSortDir(null)
  }
}

const sorted = useMemo(() => {
  if (!sortField || !sortDir) return originalOrder.current
  return [...data].sort((a, b) => {
    const va = a[sortField], vb = b[sortField]
    const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb))
    return sortDir === 'asc' ? cmp : -cmp
  })
}, [data, sortField, sortDir])

That useMemo is genuinely worth it at 500+ rows. Sorting runs on every render otherwise, and you'll notice it on slower devices.

Responsive Tables and Empty States

The overflow-x-auto wrapper handles most desktop-to-tablet transitions. But on mobile under 480px, you sometimes want a card-per-row layout instead of a horizontal scroll. The trick is hiding the table entirely at small breakpoints and rendering a card list instead.

{/* Mobile card view */}
<div className="block sm:hidden space-y-3">
  {rows.map(row => (
    <div key={row.id} className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
      <div className="flex items-center justify-between">
        <span className="font-medium">{row.name}</span>
        <StatusBadge status={row.status} />
      </div>
      <p className="mt-1 text-sm text-zinc-500">{row.revenue}</p>
    </div>
  ))}
</div>

{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
  {/* your table markup */}
</div>

Empty states are another thing people leave as an afterthought. A table with no rows should tell the user why it's empty — and ideally give them an action. Don't just render an empty <tbody>.

{rows.length === 0 && (
  <tr>
    <td colSpan={columns.length} className="px-4 py-12 text-center">
      <p className="text-zinc-500">No results found.</p>
      <button className="mt-2 text-sm text-blue-600 hover:underline">
        Clear filters
      </button>
    </td>
  </tr>
)}

The colSpan={columns.length} makes the empty-state cell span the full table width. Without it you get a single cell in the first column and a bunch of empty cells beside it — looks broken.

If your table fits inside a larger design system, you might also want to look at how Empire UI handles glassmorphism components for card-based fallback layouts — the frosted card pattern works well as a mobile alternative to data-dense tables. You can also pull utility values from the gradient generator if you want to give your table header a subtle gradient instead of a flat zinc-50 background.

Putting It All Together: Production Checklist

Before shipping a data table, run through this. It's short, but every item on it is something I've seen break in production.

Visual layer: border-collapse on <table>, overflow-x-auto on wrapper, consistent px- and py- across header and body cells, tabular-nums on numeric columns, dark mode tested with actual content (long strings, empty cells, badge overflow).

Interaction layer: hover: state visually distinct from stripe colors, cursor-pointer on clickable rows, focus-visible:ring on interactive elements, aria-sort on sorted headers, colSpan empty state with user-facing action.

Performance: useMemo on sort logic if row count exceeds ~200, virtualization (TanStack Virtual) if you're rendering 1000+ rows, debounce filter inputs that re-sort on every keystroke.

That said, don't over-engineer it. Most internal tools have under 200 rows per page. The full sortable pattern above handles that just fine without virtual scrolling or server-side pagination. Add complexity when you actually hit the limit, not in anticipation of it. The table patterns here will serve 90% of what you'll build — and they stay readable in code reviews six months later, which counts for a lot.

FAQ

Does Tailwind have a built-in table component?

No, Tailwind is utility-first — there's no pre-built <Table> component. You compose the styles yourself using utilities like border-collapse, odd:bg-zinc-50, and px-4 py-3. That's actually the point; you own the markup completely.

How do I make a Tailwind table scrollable on mobile?

Wrap the <table> in a <div className="overflow-x-auto">. This lets the table keep its natural column widths while the container scrolls horizontally on narrow screens. Don't put overflow-x-auto on the table itself — it won't work.

What's the right way to add striped rows in Tailwind v3?

Use odd:bg-white even:bg-zinc-50 on each <tr> element. These pseudo-class variants are available in Tailwind v3+ without any config changes. Add the dark: prefix equivalents for dark mode support.

Should I use a library like TanStack Table instead of building my own?

For simple display tables — under 500 rows, basic sorting, no virtualization — the pattern in this guide is all you need. Reach for TanStack Table when you need server-side pagination, column resizing, or virtualized rows.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Neobrutalism in Tailwind CSS: Bold Shadows, Raw Typography, Loud ColorBuilding a Landing Page in Tailwind CSS: Section by SectionGlassmorphism Table: Frosted Data Table in React + TailwindCSS Box Shadow: The Complete Guide With Live Examples