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
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.jsonAI 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;
}