Skip to main content
Back to Blog
Tutorials
3 min read
December 24, 2024

How to Build Responsive Data Tables Without a Library in React

Create data tables that work beautifully on mobile devices using card layouts, horizontal scrolling, collapsible rows, and priority columns.

Ryel Banfield

Founder & Lead Developer

Tables are notoriously difficult on mobile. Here are four patterns that solve this.

Pattern 1: Horizontal Scroll

The simplest approach — wrap the table in a scrollable container.

export function ScrollTable({ children }: { children: React.ReactNode }) {
  return (
    <div className="relative border rounded-lg">
      <div className="overflow-x-auto">
        <table className="w-full min-w-[640px] text-sm">{children}</table>
      </div>
      {/* Scroll shadow indicators */}
      <div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
    </div>
  );
}

Pattern 2: Card Layout on Mobile

"use client";

import { useMediaQuery } from "@/hooks/useMediaQuery";

interface Column<T> {
  key: string;
  label: string;
  render: (row: T) => React.ReactNode;
  primary?: boolean; // Show as card title
}

interface ResponsiveTableProps<T> {
  data: T[];
  columns: Column<T>[];
  keyExtractor: (row: T) => string;
}

export function ResponsiveTable<T>({
  data,
  columns,
  keyExtractor,
}: ResponsiveTableProps<T>) {
  const isMobile = useMediaQuery("(max-width: 768px)");

  if (isMobile) {
    return (
      <div className="space-y-3">
        {data.map((row) => {
          const primaryCol = columns.find((c) => c.primary);
          const otherCols = columns.filter((c) => !c.primary);

          return (
            <div key={keyExtractor(row)} className="border rounded-lg p-4">
              {primaryCol && (
                <div className="font-medium text-base mb-2">
                  {primaryCol.render(row)}
                </div>
              )}
              <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
                {otherCols.map((col) => (
                  <div key={col.key}>
                    <dt className="text-muted-foreground text-xs">{col.label}</dt>
                    <dd className="font-medium">{col.render(row)}</dd>
                  </div>
                ))}
              </dl>
            </div>
          );
        })}
      </div>
    );
  }

  return (
    <div className="border rounded-lg overflow-hidden">
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b bg-muted/50">
            {columns.map((col) => (
              <th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
                {col.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {data.map((row) => (
            <tr key={keyExtractor(row)} className="border-b last:border-0 hover:bg-muted/30">
              {columns.map((col) => (
                <td key={col.key} className="px-4 py-3">
                  {col.render(row)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Pattern 3: Collapsible Rows

"use client";

import { useState } from "react";

interface CollapsibleRow<T> {
  data: T;
  primaryColumns: { key: string; label: string; render: (row: T) => React.ReactNode }[];
  secondaryColumns: { key: string; label: string; render: (row: T) => React.ReactNode }[];
}

export function CollapsibleTable<T>({
  rows,
  keyExtractor,
}: {
  rows: CollapsibleRow<T>[];
  keyExtractor: (row: T) => string;
}) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());

  const toggle = (id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return (
    <div className="border rounded-lg overflow-hidden">
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b bg-muted/50">
            <th className="w-8 px-2" />
            {rows[0]?.primaryColumns.map((col) => (
              <th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
                {col.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => {
            const id = keyExtractor(row.data);
            const isExpanded = expanded.has(id);

            return (
              <>
                <tr
                  key={id}
                  className="border-b cursor-pointer hover:bg-muted/30"
                  onClick={() => toggle(id)}
                >
                  <td className="px-2 text-center">
                    <span
                      className={`inline-block transition-transform ${isExpanded ? "rotate-90" : ""}`}
                    >
                      &#9654;
                    </span>
                  </td>
                  {row.primaryColumns.map((col) => (
                    <td key={col.key} className="px-4 py-3">
                      {col.render(row.data)}
                    </td>
                  ))}
                </tr>
                {isExpanded && (
                  <tr key={`${id}-details`} className="border-b bg-muted/10">
                    <td colSpan={row.primaryColumns.length + 1} className="px-6 py-3">
                      <dl className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
                        {row.secondaryColumns.map((col) => (
                          <div key={col.key}>
                            <dt className="text-muted-foreground text-xs">{col.label}</dt>
                            <dd className="font-medium mt-0.5">{col.render(row.data)}</dd>
                          </div>
                        ))}
                      </dl>
                    </td>
                  </tr>
                )}
              </>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

Pattern 4: Column Priority

"use client";

import { useMediaQuery } from "@/hooks/useMediaQuery";

interface PriorityColumn<T> {
  key: string;
  label: string;
  render: (row: T) => React.ReactNode;
  priority: 1 | 2 | 3; // 1 = always visible, 2 = tablet+, 3 = desktop only
}

export function PriorityTable<T>({
  data,
  columns,
  keyExtractor,
}: {
  data: T[];
  columns: PriorityColumn<T>[];
  keyExtractor: (row: T) => string;
}) {
  const isTablet = useMediaQuery("(min-width: 768px)");
  const isDesktop = useMediaQuery("(min-width: 1024px)");

  const visibleColumns = columns.filter((col) => {
    if (col.priority === 1) return true;
    if (col.priority === 2) return isTablet;
    if (col.priority === 3) return isDesktop;
    return false;
  });

  return (
    <div className="border rounded-lg overflow-hidden">
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b bg-muted/50">
            {visibleColumns.map((col) => (
              <th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
                {col.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {data.map((row) => (
            <tr key={keyExtractor(row)} className="border-b last:border-0 hover:bg-muted/30">
              {visibleColumns.map((col) => (
                <td key={col.key} className="px-4 py-3">
                  {col.render(row)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

useMediaQuery Hook

// hooks/useMediaQuery.ts
"use client";

import { useEffect, useState } from "react";

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia(query);
    setMatches(mql.matches);

    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

Need Mobile-Optimized Interfaces?

We specialize in responsive design that works on every device. Get in touch to discuss your project.

responsivetablesmobileCSSReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles