Skip to main content
Mobile-First Tables: Fixing Touch-Scroll Conflicts

Mobile-First Tables: Fixing Touch-Scroll Conflicts

Andrius LukminasAndrius LukminasJanuary 30, 20266 min read20 views

Every SaaS admin panel has tables. And every table works great on desktop — until someone opens it on a phone. Suddenly, rows are too wide, tap targets are too small, and trying to scroll horizontally accidentally triggers row actions. We had this problem across 15+ data tables in Boottify's control center.

This is the story of how we built ResponsiveTable, TouchSafeClick, and useLongPress — three components that eliminated the problem globally.

THE PROBLEM: SCROLL VS. TAP

On mobile, when a table overflows horizontally, users need to swipe left/right to see all columns. But if row elements have click handlers (edit buttons, status toggles, checkboxes), the browser can't distinguish between a tap and the start of a swipe gesture.

The result: users trying to scroll end up accidentally triggering actions. Users trying to tap end up scrolling instead. It's a lose-lose UX.

What We Tried (And Why It Failed)

  • CSS overflow-x: auto — Enables scrolling but doesn't fix tap conflicts
  • touch-action: pan-x — Breaks vertical scrolling on the page
  • Swipeable card layouts — Too much refactoring for 15+ different table schemas

THE SOLUTION: THREE COMPONENTS

1. ResponsiveTable

A single table component that handles all the responsive logic. On desktop, it renders a normal <table>. On mobile, it transforms each row into a stacked card layout with an action menu.

import { ResponsiveTable, type Column } from "@/components/ui/responsive-table";

const columns: Column<User>[] = [
  { key: "name", header: "Name", accessor: (row) => row.name },
  { key: "email", header: "Email", accessor: (row) => row.email },
  { key: "role", header: "Role", accessor: (row) => <StatusBadge status={row.role} /> },
];

<ResponsiveTable
  columns={columns}
  data={users}
  keyExtractor={(row) => row.id}
  renderRowActions={(row) => [
    { label: "Edit", onClick: () => edit(row) },
    { label: "Delete", onClick: () => remove(row), variant: "danger" },
  ]}
/>

2. TouchSafeClick

A wrapper component that distinguishes between taps and scroll gestures by tracking touch movement distance. If the finger moves more than 10px from the start position, it's a scroll — suppress the click.

// Simplified implementation
function TouchSafeClick({ onClick, children }) {
  const startPos = useRef({ x: 0, y: 0 });
  const moved = useRef(false);

  return (
    <div
      onTouchStart={(e) => {
        startPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
        moved.current = false;
      }}
      onTouchMove={(e) => {
        const dx = Math.abs(e.touches[0].clientX - startPos.current.x);
        const dy = Math.abs(e.touches[0].clientY - startPos.current.y);
        if (dx > 10 || dy > 10) moved.current = true;
      }}
      onClick={(e) => {
        if (moved.current) { e.preventDefault(); return; }
        onClick?.(e);
      }}
    >
      {children}
    </div>
  );
}

3. useLongPress Hook

For mobile action menus, we use long-press instead of hover. The useLongPress hook provides a clean API for detecting 500ms press-and-hold gestures with haptic feedback.

THE IMPACT

By migrating all tables to ResponsiveTable, we fixed touch-scroll conflicts across 15+ admin pages in a single pass. Every table now has:

  • Smooth horizontal scrolling on mobile without accidental action triggers
  • Stacked card layout for narrow screens
  • Long-press action menus that feel native
  • Consistent column sorting and filtering

The best part: new tables automatically get all these features. Developers just define columns and data — the component handles everything else.

Related Articles

Comments

0/5000 characters

Comments from guests require moderation.