Auto Table
Paginated table that auto-adjusts visible rows to container height.
Afhængigheder:
motionSimpel tabel
| Language | Released | Paradigm |
|---|---|---|
| TypeScript | 2012 | Multi-paradigm |
| Rust | 2015 | Multi-paradigm |
| Go | 2009 | Concurrent |
| Python | 1991 | Multi-paradigm |
| Swift | 2014 | Multi-paradigm |
1 / 3
Users tabel
| Lead name | Status | Deal Value | Assigned To | Interacted | Payment Status | Date Added |
|---|---|---|---|---|---|---|
| Sarah Parker3 | Won | $2,500.00 | Sarah P.[C] | 2 days ago | Full Payment | 1 month ago |
| Mike Brown | Call Booked | Pending...– | Alex W.[C] | 3 hours ago | N/A | 5 days ago |
| Linda Chen | Unqualified - No Money | N/A– | Jane D.[S] | 1 week ago | N/A | 2 weeks ago |
| David Lee | Won | $5,000.00 | John S.[C] | 4 days ago | $1K Deposit | 6 days ago |
| Emily White5 | No Show | Pending...– | Ali M.[S] | 15 mins ago | N/A | 1 day ago |
1 / 3
Installer
npx shadcn@latest add https://byhartvig.dk/r/auto-table.jsonBrug
import { AutoTable } from "@/components/ui/auto-table"
const columns = [
{ key: "name", header: "Name" },
{ key: "role", header: "Role" },
]
const data = [
{ name: "Alice", role: "Engineer" },
{ name: "Bob", role: "Designer" },
]
<AutoTable columns={columns} data={data} rowKey="name" />Kildekode
"use client";
import {
useState,
useLayoutEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { motion, AnimatePresence } from "motion/react";
interface Column<T> {
key: string;
header: string;
render?: (value: unknown, row: T, rowIndex: number) => ReactNode;
width?: string;
align?: "left" | "center" | "right";
}
interface AutoTableProps<T extends Record<string, unknown>> {
columns: Column<T>[];
data: T[];
rowHeight?: number;
itemsPerPage?: number;
className?: string;
caption?: string;
rowKey?: keyof T;
}
const PAGINATION_HEIGHT = 38;
function useAutoPageSize(
containerRef: React.RefObject<HTMLDivElement | null>,
theadRef: React.RefObject<HTMLTableSectionElement | null>,
rowHeight: number,
totalRows: number,
manualOverride?: number
) {
const [pageSize, setPageSize] = useState(manualOverride ?? 5);
useLayoutEffect(() => {
if (manualOverride != null) {
setPageSize(manualOverride);
return;
}
const container = containerRef.current;
if (!container) return;
let rafId = 0;
const measure = () => {
const containerHeight = container.clientHeight;
const theadHeight = theadRef.current?.offsetHeight ?? 0;
const available = containerHeight - theadHeight;
const rawFit = Math.max(1, Math.floor(available / rowHeight));
if (rawFit >= totalRows) {
// All rows fit without pagination — no pagination needed
setPageSize(rawFit);
} else {
// Need pagination — reserve space for the pagination bar
const withPagination = Math.max(1, Math.floor((available - PAGINATION_HEIGHT) / rowHeight));
// Guard: if reserving pagination space would fit all rows,
// still keep pagination to avoid oscillation at the boundary
setPageSize(Math.min(withPagination, totalRows - 1));
}
};
measure();
const observer = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(measure);
});
observer.observe(container);
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [containerRef, theadRef, rowHeight, totalRows, manualOverride]);
return pageSize;
}
const spring = { type: "spring" as const, stiffness: 500, damping: 35, mass: 0.8 };
export function AutoTable<T extends Record<string, unknown>>({
columns,
data,
rowHeight = 44,
itemsPerPage,
className,
caption,
rowKey,
}: AutoTableProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const theadRef = useRef<HTMLTableSectionElement>(null);
const [page, setPage] = useState(0);
const navKeyRef = useRef(0);
const pageSize = useAutoPageSize(
containerRef,
theadRef,
rowHeight,
data.length,
itemsPerPage
);
const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
const currentPage = Math.min(page, totalPages - 1);
const start = currentPage * pageSize;
const visibleRows = data.slice(start, start + pageSize);
const goToPrev = useCallback(() => {
navKeyRef.current++;
setPage((p) => Math.max(0, p - 1));
}, []);
const goToNext = useCallback(() => {
navKeyRef.current++;
setPage((p) => p + 1);
}, []);
const alignClass = (align?: "left" | "center" | "right") =>
align === "center"
? "text-center"
: align === "right"
? "text-right"
: "text-left";
return (
<div
ref={containerRef}
className={`h-full flex flex-col rounded-2xl border border-current/5 bg-current/[0.02] overflow-hidden ${className ?? ""}`}
>
<div className="flex-1 overflow-hidden overflow-x-auto">
<table className="w-full border-collapse min-w-[400px]">
{caption && (
<caption className="sr-only">{caption}</caption>
)}
<thead ref={theadRef}>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={`px-4 pt-4 pb-3 text-xs font-normal text-current/40 tracking-wide ${alignClass(col.align)}`}
style={col.width ? { width: col.width } : undefined}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
<AnimatePresence key={navKeyRef.current} initial={false}>
{visibleRows.map((row, i) => {
const key = rowKey ? String(row[rowKey]) : `${start + i}`;
const isLast = i === visibleRows.length - 1;
return (
<motion.tr
key={key}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: rowHeight }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className={isLast ? "" : "border-b border-current/5"}
>
{columns.map((col, colIndex) => (
<td
key={col.key}
className={`px-4 text-sm whitespace-nowrap ${colIndex === 0 ? "font-medium" : "text-current/70"} ${alignClass(col.align)}`}
>
{col.render
? col.render(row[col.key], row, start + i)
: (row[col.key] as ReactNode)}
</td>
))}
</motion.tr>
);
})}
</AnimatePresence>
</tbody>
</table>
</div>
<AnimatePresence initial={false}>
{totalPages > 1 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: PAGINATION_HEIGHT }}
exit={{ opacity: 0, height: 0 }}
transition={spring}
className="flex items-center justify-center gap-4 px-4 overflow-hidden shrink-0"
>
<button
onClick={goToPrev}
disabled={currentPage === 0}
className="rounded-full border border-current/10 p-1.5 text-current/40 transition-colors duration-200 enabled:hover:text-current disabled:opacity-30"
aria-label="Previous page"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4L6 8L10 12" />
</svg>
</button>
<span className="text-xs text-current/40 tabular-nums">
{currentPage + 1} / {totalPages}
</span>
<button
onClick={goToNext}
disabled={currentPage === totalPages - 1}
className="rounded-full border border-current/10 p-1.5 text-current/40 transition-colors duration-200 enabled:hover:text-current disabled:opacity-30"
aria-label="Next page"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4L10 8L6 12" />
</svg>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}