Fixing Wide Tables on Mobile: Adding Horizontal Scroll without Layout Shift
TL;DR: I patched the Table component so that tables wider than the viewport no longer push the page sideways on mobile. The fix adds a responsive wrapper with overflow-x:auto and a CSS‑only scrollbar, keeping the layout stable while preserving desktop behavior.
The Problem
Our SaaS dashboard renders data‑heavy tables (e.g., contract listings, monthly reports). On a recent QA run on an iPhone 13, the page horizontally scrolled whenever a table exceeded the screen width:
LayoutError: viewport overflow – element .table-wrapper width 1240px > 375px (mobile)
The symptom was a broken UX: users had to swipe the whole page, and the fixed header disappeared off‑screen. The issue surfaced after PR #24 introduced a new column (tenantId) that doubled the table width.
What I Tried First
My first instinct was to force the table to shrink with table-layout: fixed and width: 100%. I added the following CSS directly to Table.jsx:
.table {
table-layout: fixed;
width: 100%;
}
On desktop it looked okay, but on mobile the columns collapsed into unreadable ellipses. The real data (IDs, amounts) got truncated, making the table useless. I also tried wrapping the table in a <div class="scroll-container"> with overflow-x: scroll but forgot to set display: block on the wrapper, so the scroll area collapsed to zero height. The page still shifted.
Conclusion: a simple overflow on the wrapper wasn’t enough; we needed a reliable, responsive container that only activates on small viewports while preserving the existing desktop layout.
The Implementation
1. Architecture Decision
I decided to keep the existing Table component untouched for backward compatibility and introduce a thin wrapper component called ResponsiveTable. This wrapper:
- Detects if the table’s natural width exceeds the viewport.
- Applies a horizontal scroll only on screens ≤ 768 px.
- Leaves the original table untouched for desktop, avoiding any layout regressions.
The wrapper lives in src/components/ResponsiveTable.jsx. It uses a ref to measure the table’s scroll width after render and toggles a CSS class accordingly.
2. New Files
| File | Purpose |
|---|---|
src/components/ResponsiveTable.jsx |
Wrapper component with resize observer |
src/styles/responsive-table.css |
CSS for the scrolling container |
src/components/Table.jsx (minor import) |
Exported as default, now used inside the wrapper |
3. Code Changes
src/components/ResponsiveTable.jsx
import React, { useEffect, useRef, useState } from 'react';
import Table from './Table';
import './responsive-table.css';
interface ResponsiveTableProps {
data: any[];
columns: { key: string; label: string }[];
}
/**
* Wraps <Table> and adds horizontal scroll on small screens
* when the table’s intrinsic width exceeds the viewport.
*/
export default function ResponsiveTable({ data, columns }: ResponsiveTableProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const [needsScroll, setNeedsScroll] = useState(false);
// Measure after mount and on resize
useEffect(() => {
const observe = new ResizeObserver(() => {
if (!wrapperRef.current || !tableRef.current) return;
const wrapperWidth = wrapperRef.current.clientWidth;
const tableWidth = tableRef.current.scrollWidth;
setNeedsScroll(tableWidth > wrapperWidth);
});
if (wrapperRef.current) observe.observe(wrapperRef.current);
return () => observe.disconnect();
}, []);
return (
<div
ref={wrapperRef}
className={`responsive-table-wrapper ${needsScroll ? 'scrollable' : ''}`}
>
{/* Pass the ref down so we can measure the real <table> element */}
<Table ref={tableRef} data={data} columns={columns} />
</div>
);
}
Key points:
- ResizeObserver – Guarantees the check runs on orientation change or when a user toggles the dev tools.
-
needsScrollstate – Toggles thescrollableclass only when necessary, avoiding unnecessary scrollbars on desktop. -
refforwarding –Table.jsxnow exports a forwardRef component.
Updating Table.jsx (only a few lines)
import React, { forwardRef } from 'react';
const Table = forwardRef<HTMLTableElement, TableProps>(({ data, columns }, ref) => (
<table ref={ref} className="data-table">
<thead>
<tr>{columns.map(col => <th key={col.key}>{col.label}</th>)}</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => <td key={col.key}>{row[col.key]}</td>)}
</tr>
))}
</tbody>
</table>
));
export default Table;
No visual changes; we only expose the underlying <table> for measurement.
src/styles/responsive-table.css
.responsive-table-wrapper {
width: 100%;
/* Prevent vertical overflow on mobile */
overflow-y: hidden;
}
/* Only enable horizontal scrolling when the class is added */
.responsive-table-wrapper.scrollable {
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* smooth scrolling on iOS */
}
/* Optional: hide native scrollbar on WebKit while keeping scrollability */
.responsive-table-wrapper.scrollable::-webkit-scrollbar {
height: 6px;
}
.responsive-table-wrapper.scrollable::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
/* Preserve desktop layout */
@media (min-width: 769px) {
.responsive-table-wrapper {
overflow: visible !important;
}
}
4. Integration
In the pages that previously imported Table directly (e.g., src/pages/Contracts.jsx), I swapped the import:
// Before
import Table from '../components/Table';
// After
import ResponsiveTable from '../components/ResponsiveTable';
And updated the JSX:
<ResponsiveTable data={contracts} columns={contractColumns} />
All other props remained unchanged, so no further refactoring was needed.
5. Testing the Fix
I added a Cypress test (cypress/integration/table_responsive.spec.js) that forces a viewport width of 375 px and asserts that the wrapper has the scrollable class when the table contains more than 8 columns.
describe('Responsive Table', () => {
it('adds horizontal scroll on mobile', () => {
cy.viewport(375, 667);
cy.visit('/contracts');
cy.get('.responsive-table-wrapper')
.should('have.class', 'scrollable')
.and('have.css', 'overflow-x', 'auto');
});
});
All CI pipelines now pass, and manual testing on iOS Safari confirms the page no longer shifts sideways.
Key Takeaway
When dealing with wide tables on responsive layouts, isolate the scrolling logic in a dedicated wrapper that measures the table’s intrinsic width rather than relying on static CSS breakpoints. This pattern keeps the desktop experience untouched and only adds scroll when it’s truly needed, avoiding layout shift and preserving accessibility.
What's Next
- Lazy‑load column data – For tables with > 50 columns, fetch column definitions on demand to reduce initial payload.
-
Keyboard navigation – Ensure the scrollable wrapper is focusable (
tabindex="0") so keyboard users
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-10-19
#playadev #buildinpublic
Top comments (0)