Skip to main content
Frontend6 min readJune 18, 2025

Responsive Data Tables That Actually Work on Mobile

Build data tables that remain usable on every screen size — responsive patterns, horizontal scroll, column prioritization, and card-based mobile layouts.

James Ross Jr.
James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Data tables are one of the hardest UI patterns to get right on small screens. A table that works perfectly at 1440 pixels becomes unusable at 375 pixels — columns compress until text wraps into illegibility, or the table overflows and important columns disappear off-screen. The standard advice to "just make it responsive" ignores the fundamental problem: tables are two-dimensional data structures being displayed on a one-dimensional viewport.

There is no single solution. The right approach depends on the data, the user's task, and how many columns your table actually needs.

Horizontal Scroll With Sticky Columns

The simplest approach — and often the best — is letting the table scroll horizontally while pinning the most important column in place. Users on mobile are accustomed to horizontal scrolling in tables because it preserves the tabular layout they expect.

<template>
 <div class="overflow-x-auto -mx-4 px-4">
 <table class="w-full min-w-[640px]">
 <thead>
 <tr>
 <th class="sticky left-0 z-10 bg-white">Name</th>
 <th>Email</th>
 <th>Role</th>
 <th>Status</th>
 <th>Last Active</th>
 </tr>
 </thead>
 <tbody>
 <tr v-for="user in users" :key="user.id">
 <td class="sticky left-0 z-10 bg-white font-medium">
 {{ user.name }}
 </td>
 <td>{{ user.email }}</td>
 <td>{{ user.role }}</td>
 <td><StatusBadge :status="user.status" /></td>
 <td>{{ formatDate(user.lastActive) }}</td>
 </tr>
 </tbody>
 </table>
 </div>
</template>

The sticky left-0 keeps the name column visible while other columns scroll. The min-w-[640px] prevents columns from compressing below their readable minimum. The negative margin with equal padding on the wrapper extends the scroll area to the screen edges, which feels more natural than a scroll container inset from the edges.

Add a subtle shadow on the sticky column's right edge to indicate there is more content to scroll to. Without this visual cue, users may not realize the table is scrollable.

This pattern works for tables with up to about eight columns. Beyond that, even horizontal scrolling becomes tedious and users lose context about which row they are reading.

Column Prioritization

Not all columns are equally important. A user management table might have name, email, role, status, last active date, created date, and phone number — but on mobile, the user probably only needs name, role, and status to accomplish their task.

The pattern is to assign priority levels to columns and hide lower-priority columns as the viewport shrinks:

<script setup lang="ts">
interface Column {
 key: string
 label: string
 priority: 'high' | 'medium' | 'low'
}

Const columns: Column[] = [
 { key: 'name', label: 'Name', priority: 'high' },
 { key: 'role', label: 'Role', priority: 'high' },
 { key: 'status', label: 'Status', priority: 'high' },
 { key: 'email', label: 'Email', priority: 'medium' },
 { key: 'lastActive', label: 'Last Active', priority: 'medium' },
 { key: 'phone', label: 'Phone', priority: 'low' },
]
</script>

<template>
 <table>
 <thead>
 <tr>
 <th
 v-for="col in columns"
 :key="col.key"
 :class="{
 'hidden md:table-cell': col.priority === 'medium',
 'hidden lg:table-cell': col.priority === 'low',
 }"
 >
 {{ col.label }}
 </th>
 </tr>
 </thead>
 </table>
</template>

Tailwind's responsive utilities make this clean — hidden md:table-cell hides the column below the md breakpoint and shows it above. The hidden data should be accessible through a row expansion or detail view so users can still reach it when needed.

Combine column prioritization with a row detail pattern. Tapping a row on mobile expands it to show the hidden columns in a stacked layout below the row. This gives mobile users access to all data without cramming it into a narrow table.

Card Layout Transformation

For tables where each row represents a distinct entity — orders, invoices, users — transforming the table into a card stack on mobile often provides a better experience than any table-based responsive pattern.

<template>
 <!-- Table for desktop -->
 <table class="hidden md:table w-full">
 <!-- standard table markup -->
 </table>

 <!-- Cards for mobile -->
 <div class="md:hidden space-y-3">
 <div
 v-for="user in users"
 :key="user.id"
 class="rounded-lg border p-4"
 >
 <div class="flex items-center justify-between">
 <span class="font-medium">{{ user.name }}</span>
 <StatusBadge :status="user.status" />
 </div>
 <dl class="mt-2 space-y-1 text-sm text-neutral-600">
 <div class="flex justify-between">
 <dt>Role</dt>
 <dd>{{ user.role }}</dd>
 </div>
 <div class="flex justify-between">
 <dt>Email</dt>
 <dd>{{ user.email }}</dd>
 </div>
 </dl>
 </div>
 </div>
</template>

The downside of the card pattern is that it eliminates column alignment, which makes comparing values across rows harder. If the user's task is scanning a column to find outliers — "which orders shipped late?" — cards are worse than a table. If the task is reviewing individual records — "show me the details of this order" — cards are better.

The choice should be driven by the primary user task, which connects back to the product design decisions covered in approaches like building dashboard interfaces. The table is a data presentation tool, and the right presentation depends on what question the user is asking.

Sorting, Filtering, and Accessibility

Tables need sorting and filtering controls regardless of screen size. On mobile, inline column headers with sort toggles work for sorting. Filtering is harder — a filter bar above the table takes up space that mobile viewports cannot afford.

The pattern that works is a filter button that opens a slide-over panel with all filter options. Applied filters show as removable chips below the button, so the user can see active filters without opening the panel.

For accessibility, data tables need proper semantic markup. Use <th scope="col"> for column headers and <th scope="row"> for row headers. The <caption> element provides an accessible name for the table. Screen readers use these to announce cell positions: "Row 3, Name column: Jane Smith."

<table>
 <caption class="sr-only">User management — 47 users</caption>
 <thead>
 <tr>
 <th scope="col">
 <button @click="sort('name')" aria-label="Sort by name">
 Name
 <SortIcon :direction="sortDirection('name')" />
 </button>
 </th>
 </tr>
 </thead>
</table>

Sort buttons in column headers should indicate the current sort direction with both a visual icon and an aria-label that includes the direction. "Sort by name, currently ascending" tells screen reader users the current state and what clicking will do. This level of detail in accessible interactive elements makes the difference between a table that is technically accessible and one that is genuinely usable.