Mobile-First Tables — Complete Guide
In this tutorial, you will learn about Mobile. We cover key concepts, practical examples, and best practices to help you master this topic.
Mobile-first tables use horizontal scroll, card layout conversion, responsive resizing, sticky headers, and data prioritization for readable data on small screens.
What You'll Learn
- Horizontal scroll tables
- Card layout conversion
- Responsive table resizing
- Sticky headers and columns
- Data prioritization techniques
- Sortable and filterable tables
- Accessible table markup
Why It Matters
- Tables are the worst UI element on mobile
- Users scroll horizontally or give up entirely
- Small text and narrow columns cause errors
- Poor table design loses data comprehension
Real-World Use
- A stock portfolio table with sticky first column
- A product comparison table using card layout
- A leaderboard that sorts by column on tap
- A dashboard table that prioritizes key metrics
flowchart LR A[Mobile-First Tables] --> B[Horizontal Scroll] A --> C[Card Layout] A --> D[Resizing] A --> E[Sticky] B --> F[overflow-x] C --> G[CSS Grid] D --> H[Column priority] E --> I[Headers + first col]
Horizontal Scroll Tables
The simplest approach: allow horizontal scrolling within a container while keeping the table structure intact.
Code Example: Scrollable Table
<div class="table-container">
<table class="responsive-table">
<thead>
<tr>
<th>Product</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th>Rating</th>
<th>Sales</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wireless Mouse</td>
<td>Electronics</td>
<td>$29.99</td>
<td>142</td>
<td>4.5</td>
<td>1,234</td>
<td>Active</td>
</tr>
<tr>
<td>USB-C Hub</td>
<td>Accessories</td>
<td>$49.99</td>
<td>89</td>
<td>4.2</td>
<td>856</td>
<td>Active</td>
</tr>
<tr>
<td>Mechanical Keyboard</td>
<td>Electronics</td>
<td>$89.99</td>
<td>34</td>
<td>4.8</td>
<td>2,101</td>
<td>Low Stock</td>
</tr>
</tbody>
</table>
</div>
<style>
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 1rem 0;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.responsive-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
min-width: 600px;
}
.responsive-table th,
.responsive-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
}
.responsive-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
position: sticky;
top: 0;
z-index: 1;
}
.responsive-table tbody tr:hover {
background: #f3f4f6;
}
/* Scroll indicator on mobile */
.table-container::after {
content: 'Scroll for more columns >>';
display: block;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: #6b7280;
text-align: right;
}
@media (min-width: 768px) {
.table-container::after {
display: none;
}
}
</style>
Expected output: On mobile, the table scrolls horizontally with a visual indicator. The sticky header stays visible as the user scrolls. On wide screens, the full table is visible without scrolling.
Card Layout Conversion
Convert table rows into cards on small screens. Each row becomes a visually distinct card with labeled fields.
Code Example: Table to Cards
<table class="card-table">
<thead>
<tr>
<th>Name</th>
<th>Department</th>
<th>Role</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Name">Alice Chen</td>
<td data-label="Department">Engineering</td>
<td data-label="Role">Senior Developer</td>
<td data-label="Email">alice@example.com</td>
<td data-label="Status" class="status-active">Active</td>
</tr>
<tr>
<td data-label="Name">Bob Martinez</td>
<td data-label="Department">Design</td>
<td data-label="Role">UI Designer</td>
<td data-label="Email">bob@example.com</td>
<td data-label="Status" class="status-away">Away</td>
</tr>
<tr>
<td data-label="Name">Carol Singh</td>
<td data-label="Department">Marketing</td>
<td data-label="Role">Content Lead</td>
<td data-label="Email">carol@example.com</td>
<td data-label="Status" class="status-active">Active</td>
</tr>
</tbody>
</table>
<style>
.card-table {
width: 100%;
border-collapse: collapse;
}
.card-table th {
display: none; /* Hide headers on mobile */
}
.card-table td {
display: block;
padding: 0.5rem 1rem;
border: none;
font-size: 0.875rem;
}
.card-table td::before {
content: attr(data-label);
font-weight: 600;
color: #6b7280;
display: inline-block;
width: 7rem;
margin-right: 0.5rem;
}
.card-table tbody tr {
display: block;
margin-bottom: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
background: #fff;
}
.status-active {
color: #16a34a;
}
.status-away {
color: #d97706;
}
@media (min-width: 768px) {
.card-table th {
display: table-cell;
padding: 0.75rem 1rem;
background: #f9fafb;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #e5e7eb;
}
.card-table td {
display: table-cell;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
}
.card-table td::before {
display: none;
}
.card-table tbody tr {
display: table-row;
border: none;
border-radius: 0;
padding: 0;
margin-bottom: 0;
}
}
</style>
Expected output: On mobile, each row becomes a card with labeled fields. Headers are hidden and field names appear inline using the data-label attribute. On desktop, it displays as a normal table.
Column Priority and Responsive Resizing
Hide less important columns on small screens and progressively show them as the viewport grows.
Code Example: Priority-Based Table
<table class="priority-table">
<thead>
<tr>
<th class="always">Product</th>
<th class="priority-1">Category</th>
<th class="always">Price</th>
<th class="priority-2">Stock</th>
<th class="priority-3">Rating</th>
<th class="priority-3">Sales</th>
<th class="priority-4">Last Updated</th>
</tr>
</thead>
<tbody>
<tr>
<td class="always">Wireless Mouse</td>
<td class="priority-1">Electronics</td>
<td class="always">$29.99</td>
<td class="priority-2">142</td>
<td class="priority-3">4.5</td>
<td class="priority-3">1,234</td>
<td class="priority-4">2026-06-27</td>
</tr>
<tr>
<td class="always">USB-C Hub</td>
<td class="priority-1">Accessories</td>
<td class="always">$49.99</td>
<td class="priority-2">89</td>
<td class="priority-3">4.2</td>
<td class="priority-3">856</td>
<td class="priority-4">2026-06-27</td>
</tr>
<tr>
<td class="always">Mechanical Keyboard</td>
<td class="priority-1">Electronics</td>
<td class="always">$89.99</td>
<td class="priority-2">34</td>
<td class="priority-3">4.8</td>
<td class="priority-3">2,101</td>
<td class="priority-4">2026-06-26</td>
</tr>
</tbody>
</table>
<style>
.priority-table {
width: 100%;
border-collapse: collapse;
}
.priority-table th,
.priority-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
}
.priority-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
}
/* Priority levels: lower = more important */
@media (max-width: 360px) {
.priority-3,
.priority-4 {
display: none;
}
}
@media (min-width: 361px) and (max-width: 480px) {
.priority-4 {
display: none;
}
}
@media (min-width: 481px) and (max-width: 768px) {
/* All visible */
}
.priority-table tbody tr:hover {
background: #f3f4f6;
}
.priority-table th.always,
.priority-table td.always {
position: sticky;
left: 0;
background: #fff;
z-index: 1;
}
.priority-table th.always {
background: #f9fafb;
z-index: 2;
}
</style>
Expected output: On very small screens (under 360px), only the product name, price, and category are visible. Rating and sales appear on wider screens. Last updated only shows on large screens.
Sticky Columns and Row Headers
Keep the first column and header row visible during horizontal and vertical scroll.
Code Example: Sticky Headers and Columns
<div class="sticky-table-wrapper">
<table class="sticky-table">
<thead>
<tr>
<th class="row-header">Metric</th>
<th>Q1 2026</th>
<th>Q2 2026</th>
<th>Q3 2026</th>
<th>Q4 2026</th>
<th>YoY Change</th>
</tr>
</thead>
<tbody>
<tr>
<td class="row-header">Revenue</td>
<td>$1.2M</td>
<td>$1.5M</td>
<td>$1.8M</td>
<td>$2.1M</td>
<td class="positive">+23%</td>
</tr>
<tr>
<td class="row-header">Users</td>
<td>45,200</td>
<td>52,100</td>
<td>61,300</td>
<td>74,500</td>
<td class="positive">+65%</td>
</tr>
<tr>
<td class="row-header">Churn</td>
<td>5.2%</td>
<td>4.8%</td>
<td>4.1%</td>
<td>3.5%</td>
<td class="positive">-1.7%</td>
</tr>
</tbody>
</table>
</div>
<style>
.sticky-table-wrapper {
overflow: auto;
max-height: 400px;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.sticky-table {
width: 100%;
border-collapse: collapse;
min-width: 500px;
}
.sticky-table th,
.sticky-table td {
padding: 0.75rem 1rem;
text-align: right;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
}
.sticky-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
position: sticky;
top: 0;
z-index: 3;
}
.sticky-table .row-header {
text-align: left;
font-weight: 600;
color: #374151;
position: sticky;
left: 0;
background: #fff;
z-index: 2;
}
.sticky-table th.row-header {
background: #f9fafb;
z-index: 4;
}
.positive {
color: #16a34a;
}
.negative {
color: #ef4444;
}
</style>
Expected output: The header row stays fixed at the top during vertical scroll. The first column (Metric) stays fixed on the left during horizontal scroll. The intersection cell in the top-left corner stays fixed in both directions.
Sortable and Filterable Tables
Add interactivity to make tables usable on mobile without overwhelming the user.
Code Example: Sortable Table
<div class="sortable-wrapper">
<div class="table-controls">
<input
type="search"
id="table-filter"
placeholder="Filter products..."
inputmode="search"
>
<select id="table-category">
<option value="">All Categories</option>
<option value="Electronics">Electronics</option>
<option value="Accessories">Accessories</option>
<option value="Clothing">Clothing</option>
</select>
</div>
<div class="table-container">
<table class="sortable-table" id="sortable-table">
<thead>
<tr>
<th data-sort="name">Product <span class="sort-indicator"></span></th>
<th data-sort="category">Category <span class="sort-indicator"></span></th>
<th data-sort="price">Price <span class="sort-indicator"></span></th>
<th data-sort="stock">Stock <span class="sort-indicator"></span></th>
</tr>
</thead>
<tbody id="table-body">
<tr>
<td>Wireless Mouse</td>
<td>Electronics</td>
<td>29.99</td>
<td>142</td>
</tr>
<tr>
<td>USB-C Hub</td>
<td>Accessories</td>
<td>49.99</td>
<td>89</td>
</tr>
<tr>
<td>Mechanical Keyboard</td>
<td>Electronics</td>
<td>89.99</td>
<td>34</td>
</tr>
<tr>
<td>Mouse Pad</td>
<td>Accessories</td>
<td>14.99</td>
<td>300</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
const table = document.getElementById('sortable-table');
const tbody = document.getElementById('table-body');
const filter = document.getElementById('table-filter');
const categoryFilter = document.getElementById('table-category');
let sortState = { key: null, asc: true };
table.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', function() {
const key = this.dataset.sort;
const asc = sortState.key === key ? !sortState.asc : true;
sortState = { key, asc };
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aVal = a.children[Array.from(th.parentNode.children).indexOf(th)].textContent;
const bVal = b.children[Array.from(th.parentNode.children).indexOf(th)].textContent;
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
const result = !isNaN(aNum) && !isNaN(bNum) ? aNum - bNum : aVal.localeCompare(bVal);
return asc ? result : -result;
});
rows.forEach(row => tbody.appendChild(row));
table.querySelectorAll('.sort-indicator').forEach(ind => ind.textContent = '');
this.querySelector('.sort-indicator').textContent = asc ? ' ▲' : ' ▼';
});
});
function filterTable() {
const query = filter.value.toLowerCase();
const category = categoryFilter.value.toLowerCase();
tbody.querySelectorAll('tr').forEach(row => {
const text = row.textContent.toLowerCase();
const catMatch = !category || row.children[1].textContent.toLowerCase() === category;
const queryMatch = text.includes(query);
row.style.display = queryMatch && catMatch ? '' : 'none';
});
}
filter.addEventListener('input', filterTable);
categoryFilter.addEventListener('change', filterTable);
</script>
<style>
.table-controls {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.table-controls input,
.table-controls select {
flex: 1;
min-width: 200px;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
border: 2px solid #d1d5db;
border-radius: 8px;
font-family: inherit;
}
.table-controls input:focus,
.table-controls select:focus {
border-color: #3b82f6;
outline: none;
}
.sortable-table th[data-sort] {
cursor: pointer;
user-select: none;
}
.sortable-table th[data-sort]:hover {
background: #e5e7eb;
}
</style>
Expected output: Users can type in the filter box to search across all columns. The category dropdown filters by department. Clicking any column header sorts the table by that column, toggling between ascending and descending order.
Common Mistakes
- No horizontal scroll container — The table overflows the viewport without scroll, forcing users to zoom out to see all columns.
- All columns equal priority — No visual hierarchy. Users must scan everything to find the data they need.
- Tiny font size — Tables with font-size under 14px are unreadable on mobile without zooming.
- No sticky headers — Users scroll down and forget which column contains which data.
- Card layout without data-label — Converting to cards without prepending field labels makes the data unidentifiable.
- Touch targets too small — Sortable column headers and filter controls need at least 44px height for tapping.
- No accessible sort announcement — Screen readers do not announce sort state changes without aria-live regions.
Practice Questions
- What CSS property enables horizontal scrolling for tables? overflow-x: auto on the wrapper element.
- How do you convert a table to cards on mobile? Display table cells as block elements and use CSS data-label to prepend field names.
- What is the column priority technique? Assigning priority classes to columns and hiding lower-priority columns at smaller viewport widths.
- How do you make a table header sticky? position: sticky with top: 0 on th elements.
- What attribute provides labels for card-style table cells? The data-label attribute on td elements, accessed via CSS content: attr(data-label).
Challenge
Build a financial dashboard table showing stock portfolio data. Include: (1) 8+ columns (Symbol, Name, Price, Change, Volume, Market Cap, P/E Ratio, Dividend Yield), (2) priority-based hiding on mobile, (3) sticky first column (Symbol) and header row, (4) sortable by any column, (5) filter by sector dropdown, (6) conditional formatting for positive/negative change (green/red), (7) horizontal scroll on mobile, (8) card layout below 480px using data-label.
FAQ
{{< faq "How do I make tables accessible for screen readers?" "Use proper table markup with th scope=\"col\" and th scope=\"row\". For card layouts, use role=\"list\" and role=\"listitem\" to preserve the list semantics. Add aria-sort to sortable columns." >}}Mini Project
Build a product inventory table for a warehouse management system. Include: (1) 10 columns (SKU, Product Name, Category, Quantity, Location, Status, Last Count, Reorder Point, Supplier, Lead Time), (2) priority-based responsive hiding, (3) horizontal scroll with sticky first column (SKU), (4) sticky header, (5) sortable by Quantity, Status, and Location, (6) filter by category and status dropdowns, (7) card layout conversion below 480px using data-label, (8) color-coded status (green for In Stock, yellow for Low Stock, red for Out of Stock), (9) a scroll indicator on mobile.
What's Next
Continue with Lesson 8: Mobile-First Buttons to design touch-friendly buttons for mobile interfaces.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro