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.



