← All Artifacts

Data Table · Recipe

Adaptive Data Table

A controlled data table that becomes a card list with complete column access on Touch.

Free SurfaceMIT sourceGenerated from Catalog index
Live source preview

Open the live preview in a new page →

Install

One standard shadcn command

The Consumer receives editable source in their application. The Catalog is not a runtime dependency.

pnpm dlx shadcn@latest add https://shadcnpwa.pages.dev/registry/items/adaptive-data-table/registry.json

AI handoff

Prompt for your coding agent

Install Adaptive Data Table from the public shadcn registry, then adapt the owned source to my product. Keep the Desktop and Touch presentations, preserve keyboard and screen-reader behavior, and connect my controlled data and actions without adding network requests or persistence.

Full source

Inspect before you install.

Uses synthetic neutral records and leaves rows, filtering, paging, and actions to the Consumer.

// src/adaptive-data-table.tsx
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Sheet } from "@/components/ui/sheet";
import { useAdaptivePresentation, type Presentation } from "./use-adaptive-presentation";
import "./adaptive-artifact.css";

export interface DataTableColumn<T> {
  id: string;
  header: string;
  mobile?: boolean;
  cell?: (row: T) => ReactNode;
}

export interface AdaptiveDataTableProps<T extends Record<string, unknown>> {
  rows: readonly T[];
  columns: readonly DataTableColumn<T>[];
  rowId: (row: T) => string;
  page: number;
  pageCount: number;
  onPageChange: (page: number) => void;
  filterValue?: string;
  onFilterValueChange?: (value: string) => void;
  onRowActivate?: (row: T) => void;
  presentationOverride?: Presentation;
  copy?: {
    filterLabel?: string;
    detailsLabel?: string;
    closeLabel?: string;
    previousLabel?: string;
    nextLabel?: string;
    pageLabel?: string;
  };
}

function valueFor<T extends Record<string, unknown>>(
  column: DataTableColumn<T>,
  row: T,
) {
  return column.cell ? column.cell(row) : String(row[column.id] ?? "—");
}

export function AdaptiveDataTable<T extends Record<string, unknown>>({
  rows,
  columns,
  rowId,
  page,
  pageCount,
  onPageChange,
  filterValue = "",
  onFilterValueChange,
  onRowActivate,
  presentationOverride,
  copy,
}: AdaptiveDataTableProps<T>) {
  const presentation = useAdaptivePresentation(presentationOverride);
  const [selectedRow, setSelectedRow] = useState<T | null>(null);
  const [previousPresentation, setPreviousPresentation] =
    useState<Presentation | null>(null);
  const labels = {
    filter: copy?.filterLabel ?? "Filter records",
    details: copy?.detailsLabel ?? "View details",
    close: copy?.closeLabel ?? "Close details",
    previous: copy?.previousLabel ?? "Previous",
    next: copy?.nextLabel ?? "Next",
    page: copy?.pageLabel ?? "Page",
  };
  const isTouch = presentation === "touch";
  const primaryColumns = columns.filter((column) => column.mobile !== false);
  const secondaryColumns = columns.filter((column) => column.mobile === false);

  useEffect(() => {
    if (
      previousPresentation !== null &&
      presentation !== previousPresentation
    ) {
      setSelectedRow(null);
    }
    setPreviousPresentation(presentation);
  }, [presentation, previousPresentation]);

  const pagination = (
    <div className="adaptive-table-pagination" aria-label="Pagination">
      <Button
        className="adaptive-button"
        type="button"
        disabled={page <= 1}
        onClick={() => onPageChange(Math.max(1, page - 1))}
      >
        {labels.previous}
      </Button>
      <span aria-live="polite">{labels.page} {page} / {pageCount}</span>
      <Button
        className="adaptive-button"
        type="button"
        disabled={page >= pageCount}
        onClick={() => onPageChange(Math.min(pageCount, page + 1))}
      >
        {labels.next}
      </Button>
    </div>
  );

  if (presentation === null) {
    return <div className="adaptive-surface adaptive-card" aria-busy="true">Loading table…</div>;
  }

  return (
    <section className="adaptive-surface adaptive-grid" data-presentation={presentation}>
      {onFilterValueChange && (
        <label className="adaptive-field">
          <span>{labels.filter}</span>
          <input
            value={filterValue}
            onChange={(event) => onFilterValueChange(event.target.value)}
            type="search"
          />
        </label>
      )}
      {!isTouch ? (
        <div className="adaptive-card" tabIndex={0}>
          <table className="adaptive-table">
            <thead>
              <tr>{columns.map((column) => <th key={column.id} scope="col">{column.header}</th>)}</tr>
            </thead>
            <tbody>
              {rows.map((row) => (
                <tr key={rowId(row)}>
                  {columns.map((column) => <td key={column.id}>{valueFor(column, row)}</td>)}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        <ul className="adaptive-list">
          {rows.map((row) => (
            <li className="adaptive-card" key={rowId(row)}>
              {primaryColumns.map((column) => (
                <div key={column.id}>
                  <span className="adaptive-muted">{column.header}</span>
                  <div>{valueFor(column, row)}</div>
                </div>
              ))}
              {secondaryColumns.length > 0 && (
                <Button
                  className="adaptive-button"
                  type="button"
                  onClick={() => setSelectedRow(row)}
                  aria-label={`${labels.details}: ${rowId(row)}`}
                >
                  {labels.details}
                </Button>
              )}
              {onRowActivate && (
                <Button className="adaptive-button" type="button" onClick={() => onRowActivate(row)}>
                  {labels.details}
                </Button>
              )}
            </li>
          ))}
        </ul>
      )}
      {pagination}
      <Sheet open={selectedRow !== null} onOpenChange={(open) => !open && setSelectedRow(null)}>
        {selectedRow && (
          <div className="adaptive-overlay">
            <section className="adaptive-sheet" role="dialog" aria-modal="true" aria-labelledby="table-details-title">
              <div className="adaptive-sheet__header">
                <h2 id="table-details-title">{labels.details}</h2>
                <Button className="adaptive-button" type="button" aria-label={labels.close} onClick={() => setSelectedRow(null)}>×</Button>
              </div>
              <dl>
                {columns.map((column) => (
                  <div className="adaptive-field" key={column.id}>
                    <dt className="adaptive-muted">{column.header}</dt>
                    <dd>{valueFor(column, selectedRow)}</dd>
                  </div>
                ))}
              </dl>
            </section>
          </div>
        )}
      </Sheet>
    </section>
  );
}


// src/adaptive-artifact.css
.adaptive-surface { color: var(--foreground); background: var(--background); }
.adaptive-muted { color: var(--muted-foreground); }
.adaptive-border { border-color: var(--border); }
.adaptive-button { min-height: 2.5rem; border: 1px solid var(--border); border-radius: .5rem; background: var(--background); color: var(--foreground); padding: .55rem .8rem; cursor: pointer; }
.adaptive-button:hover { background: var(--muted); }
.adaptive-button:focus-visible, .adaptive-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.adaptive-shell { min-height: 100%; display: grid; grid-template-columns: 16rem minmax(0, 1fr); background: var(--background); color: var(--foreground); }
.adaptive-shell[data-presentation="touch"] { display: block; padding-bottom: calc(4.75rem + env(safe-area-inset-bottom)); }
.adaptive-shell__sidebar { border-right: 1px solid var(--border); padding: 1rem; }
.adaptive-shell__sidebar nav ul, .adaptive-shell__bottom nav ul { list-style: none; margin: 1rem 0 0; padding: 0; display: grid; gap: .25rem; }
.adaptive-link { display: flex; align-items: center; gap: .6rem; border-radius: .5rem; color: inherit; text-decoration: none; padding: .65rem .7rem; }
.adaptive-link[aria-current="page"] { background: var(--accent); font-weight: 650; }
.adaptive-shell__main { min-width: 0; }
.adaptive-shell__touchbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border-bottom: 1px solid var(--border); padding: max(.8rem, env(safe-area-inset-top)) 1rem .8rem; }
.adaptive-shell__content { min-width: 0; padding: 1rem; }
.adaptive-shell__bottom { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--background) 94%, transparent); padding: .35rem 1rem calc(.35rem + env(safe-area-inset-bottom)); }
.adaptive-shell__bottom nav ul { display: flex; justify-content: space-around; margin: 0; }
.adaptive-shell__bottom li { min-width: 4rem; }
.adaptive-shell__bottom .adaptive-link, .adaptive-shell__bottom .adaptive-button { width: 100%; justify-content: center; flex-direction: column; gap: .1rem; border: 0; background: transparent; padding: .35rem .25rem; font-size: .72rem; }
.adaptive-overlay { position: fixed; z-index: 20; inset: 0; background: rgb(0 0 0 / .35); }
.adaptive-sheet { position: absolute; right: 0; bottom: 0; left: 0; max-height: min(80vh, 42rem); overflow: auto; border: 1px solid var(--border); border-radius: 1rem 1rem 0 0; background: var(--background); padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom)); }
.adaptive-sheet--side { top: 0; right: 0; bottom: 0; left: auto; width: min(30rem, 92vw); max-height: none; border-radius: 1rem 0 0 1rem; padding-bottom: 1rem; }
.adaptive-sheet--full { top: 0; max-height: none; border-radius: 0; padding-top: max(1rem, env(safe-area-inset-top)); }
.adaptive-sheet__header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
.adaptive-grid { display: grid; gap: 1rem; }
.adaptive-card { border: 1px solid var(--border); border-radius: .75rem; padding: 1rem; background: var(--card, var(--background)); }
.adaptive-table { width: 100%; border-collapse: collapse; }
.adaptive-table th, .adaptive-table td { border-bottom: 1px solid var(--border); padding: .75rem .5rem; text-align: left; vertical-align: top; }
.adaptive-table th { font-size: .8rem; color: var(--muted-foreground); font-weight: 650; }
.adaptive-field { display: grid; gap: .35rem; }
.adaptive-field + .adaptive-field { margin-top: .9rem; }
.adaptive-field input, .adaptive-field select { width: 100%; min-height: 2.5rem; border: 1px solid var(--border); border-radius: .45rem; background: var(--background); color: var(--foreground); padding: .5rem .65rem; }
.adaptive-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
.adaptive-list > li { border-bottom: 1px solid var(--border); padding: .8rem 0; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }


// src/use-adaptive-presentation.ts
import { useEffect, useState } from "react";

export const PRESENTATION_BREAKPOINT = 1024;
export type Presentation = "desktop" | "touch";

export function useAdaptivePresentation(
  override?: Presentation,
): Presentation | null {
  const [presentation, setPresentation] = useState<Presentation | null>(
    override ?? null,
  );

  useEffect(() => {
    if (override) {
      setPresentation(override);
      return;
    }

    const update = () => {
      setPresentation(
        window.innerWidth >= PRESENTATION_BREAKPOINT ? "desktop" : "touch",
      );
    };
    update();

    const query = window.matchMedia(
      `(min-width: ${PRESENTATION_BREAKPOINT}px)`,
    );
    const onChange = () => update();
    if (query.addEventListener) query.addEventListener("change", onChange);
    else query.addListener(onChange);
    window.addEventListener("resize", update);
    return () => {
      window.removeEventListener("resize", update);
      if (query.removeEventListener) query.removeEventListener("change", onChange);
      else query.removeListener(onChange);
    };
  }, [override]);

  return override ?? presentation;
}