DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing Wide Tables on Mobile: Adding Horizontal Scroll without Layout Shift

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)
Enter fullscreen mode Exit fullscreen mode

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%;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • ResizeObserver – Guarantees the check runs on orientation change or when a user toggles the dev tools.
  • needsScroll state – Toggles the scrollable class only when necessary, avoiding unnecessary scrollbars on desktop.
  • ref forwarding – Table.jsx now 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;
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

And updated the JSX:

<ResponsiveTable data={contracts} columns={contractColumns} />
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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)