PVR Tech Studio

Tables & Data Grid

Two tiers of tabular UI — nine hand-styled Basic Table variants and an AG Grid Community data grid demoed across six variant routes, themed through design tokens.

9 min read
Updated July 15, 2026

Luminaux ships two tabular building blocks so you can pick the lightest tool for the job:

PageRoute(s)SourceWhen to use
Basic Table/tables/basicsrc/pages/tables/BasicTablePage.tsxSmall, read-mostly datasets. Plain HTML <table>s styled with Tailwind tokens, in Panels. No grid lib.
Data Grid/tables/data-grid/*src/pages/tables/DataGridVariants.tsx + src/components/datagrid/grids.tsxLarger, interactive datasets needing sorting, filters, selection, editing, pinning, pagination. Built on AG Grid Community.

Both live under the Data → Tables menu section, where the six grid variants form a nested Data Grid subgroup. All six grid routes are React.lazy-loaded so AG Grid splits into its own bundle chunk and never weighs down the initial app load.

The six Data Grid variants

Each variant is its own route (and menu leaf), sharing one dataset (ROWS, 12 seeded users) and one page shell. Everything used is community-safe — CSV export, quick filter, floating filters, the built-in agSelectCellEditor/agNumberCellEditor, row pinning, and column pinning are all free features.

RouteComponent (grids.tsx)What it demos
/tables/data-grid/defaultDefaultGridBaseline grid: 7 columns, sort/filter/resize on every column, pagination (10 rows, [10, 20, 50]), i18n + currency valueFormatters.
/tables/data-grid/renderersRenderersGridCustom JSX cell renderers: avatar + name/email cell, Badge status pill, icon action buttons; rowHeight={64}.
/tables/data-grid/selectionSelectionGridMulti-row selection (rowSelection={{mode: 'multiRow'}}), a live selected-count readout, and CSV export.
/tables/data-grid/searchSearchGridA quick-search Input bound to quickFilterText, plus floating filters under every header.
/tables/data-grid/editableEditableGridInline editing: editable: true columns with agSelectCellEditor/agNumberCellEditor, on a copied row set.
/tables/data-grid/pinnedPinnedGridPinned columns (pinned: 'left'/'right') + a pinned bottom total row (pinnedBottomRowData).

The nine Basic Table variants

BasicTablePage presents each variant in its own Panel (with bodyClassName="p-0 overflow-hidden") inside a Stagger/StaggerItem cascade: default, striped, bordered, compact, rich (avatar + email + per-row Dropdown action menu), super-rich (presence Avatar, role pill, Progress usage bar, spend + TrendPill, star rating, action buttons), selectable (row Checkboxes

  • select-all + a selection-count bar), sortable (clickable headers, asc/desc), and paginated (client-side slice + the Pagination component).

Architecture & files

PathResponsibility
src/components/datagrid/grids.tsxThe whole grid feature: AllCommunityModule registration, the shared Row dataset, cell renderers, useGridTheme() + useRowStagger() hooks, and the six exported grid components.
src/pages/tables/DataGridVariants.tsxSix thin route pages around a shared GridPage shell (PageHeader + icon-chip heading + Card frame).
src/pages/tables/BasicTablePage.tsxThe nine plain-HTML table variants in Panels.
src/styles/_datagrid.scssThe .ag-rows-in .ag-row keyframe + :nth-child stagger delays for the grid entrance.
src/hooks/useChartTokens.tsReads the raw --* CSS tokens off <html> so the grid theme re-skins (shared with charts).
src/data/menu.tsThe Data → Tables section: the nested Data Grid group (6 leaves) + Basic Table.

Usage

Basic Table

A Basic Table is just JSX in a Panel — semantic-token classes on a plain <table>, a Badge for the status cell. Put it in an overflow-x-auto div so it scrolls horizontally on narrow screens, and zero out the Panel body padding so rows run edge-to-edge:

const tone = {active: 'success', pending: 'warning', suspended: 'danger'} as const
const HEAD = 'border-b border-border text-left text-xs uppercase tracking-wider text-muted-foreground'
 
<Panel bodyClassName="p-0 overflow-hidden" title="Team" subtitle="Read-mostly demo data">
    <div className="overflow-x-auto">
        <table className="w-full border-collapse text-sm">
            <thead><tr className={HEAD}><th className="px-4 py-3 font-semibold">Name</th><th className="px-4 py-3 font-semibold">Status</th></tr></thead>
            <tbody>
                {rows.map((r) => (
                    <tr key={r.id} className="border-b border-border transition-colors last:border-0 hover:bg-surface-muted">
                        <td className="px-4 py-3 text-foreground">{r.name}</td>
                        <td className="px-4 py-3"><Badge tone={tone[r.status]}>{r.status}</Badge></td>
                    </tr>
                ))}
            </tbody>
        </table>
    </div>
</Panel>

Data Grid

Register the community module set once at module scope, define columns with ColDef, and theme the grid from useChartTokens(). The grid must live inside a height-bounded container — AG Grid virtualizes its rows and needs an explicit height (the variants use GRID_HEIGHT = 540).

import {AgGridReact} from 'ag-grid-react'
import {AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef} from 'ag-grid-community'
 
// AG Grid Community (free / MIT) — register the community module set once. Never
// add ag-grid-enterprise or enterprise-only features.
ModuleRegistry.registerModules([AllCommunityModule])

API / Props

Shared hooks (grids.tsx)

HookReturnsPurpose
useGridTheme()a memoized Theming-API themethemeQuartz.withParams({...}) parameterized from useChartTokens(), rebuilt on [tk] so it re-skins.
useRowStagger(){ref, onFirstDataRendered}The one-time entrance: attach ref to the grid wrapper, pass the callback to onFirstDataRendered. Gated by useReducedMotion().

AgGridReact props used across the variants

PropVariant(s)Value / purpose
themealluseGridTheme() — Theming API object.
rowDataallThe shared ROWS seed (Editable uses its own copied array).
columnDefsallColDef<Row>[] per variant.
defaultColDefall{sortable: true, filter: true, resizable: true} (Search adds floatingFilter: true).
animateRowsallAG Grid's built-in animation for sort / filter / column-move.
onFirstDataRenderedallTriggers the one-time entrance cascade (useRowStagger).
pagination + paginationPageSize + paginationPageSizeSelectorDefaultClient-side pagination: 10 rows, [10, 20, 50].
rowHeightRenderers64 — taller rows for the avatar cells.
rowSelectionSelection{mode: 'multiRow'} — the v36 object API (adds the checkbox column).
quickFilterTextSearchBound to the controlled Input value.
pinnedBottomRowDataPinnedA one-element Row[] carrying the spend total.

ColDef fields used: field, headerName, flex, maxWidth, valueFormatter, cellRenderer, editable, cellEditor + cellEditorParams, pinned: 'left' | 'right', and per-column sortable: false / filter: false. All community-safe.

Custom cell renderers

Renderers receive ICellRendererParams<Row> and return plain JSX. Two conventions worth copying:

  • Fill the cell — AG Grid doesn't vertically center custom JSX, so every renderer wraps in className="flex h-full items-center …".
  • Guard pinned rows — renderers/formatters on grids with pinnedBottomRowData check p.node.rowPinned (the actions renderer returns null on the total row).
function StatusCell(p: ICellRendererParams<Row>) {
    const v = p.value as Status | undefined
    if (!v) return null
    return <div className="flex h-full items-center"><Badge tone={STATUS_TONE[v]}>{v}</Badge></div>
}

Configuration & customization

Theming API (token-driven, so it re-skins)

AG Grid v36 uses the Theming API (JS theme objects), not the legacy CSS theme files. The shared useGridTheme() starts from themeQuartz and parameterizes it from useChartTokens() so light/dark and every color skin flow through automatically:

const tk = useChartTokens()
 
const theme = useMemo(
    () =>
        themeQuartz.withParams({
            accentColor: tk.primary,
            backgroundColor: tk.surface,
            foregroundColor: tk.foreground,
            borderColor: tk.border,
            headerBackgroundColor: tk.surfaceMuted,
            headerTextColor: tk.mutedForeground,
            rowHoverColor: tk.surfaceMuted,
            oddRowBackgroundColor: tk.background,
            fontFamily: 'inherit',
            headerFontWeight: 600,
        }),
    [tk],
)

Because useChartTokens re-reads the raw --* vars whenever <html>'s class (dark), data-skin, or data-oled attributes change, the theme object is rebuilt and the grid re-colors live — no hardcoded hex. fontFamily: 'inherit' keeps the grid on the app's skin-aware font.

The entrance animation — and why it animates translate, not transform

Every grid variant gets the same one-time staggered fade + rise as the rest of the app's entrances. But AG Grid can't use motion/react per row — it virtualizes and recycles its own row DOM. So the entrance is done in CSS, with one critical detail:

@keyframes ag-row-in {
    from { opacity: 0; translate: 0 16px; }
    to   { opacity: 1; translate: 0 0; }
}
 
.ag-rows-in .ag-row {
    // Mirrors EASE_OUT ([0.16, 1, 0.3, 1]) from src/lib/motion.ts.
    animation: ag-row-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
 
@for $i from 1 through 12 {
    .ag-rows-in .ag-row:nth-child(#{$i}) { animation-delay: #{($i - 1) * 0.05}s; }
}

The .ag-rows-in class is added once on onFirstDataRendered and removed after ~1 s so scroll-recycled rows don't replay the entrance — packaged in the shared useRowStagger() hook, gated by useReducedMotion():

function useRowStagger() {
    const reduce = useReducedMotion()
    const ref = useRef<HTMLDivElement>(null)
    const onFirstDataRendered = useCallback(() => {
        if (reduce) return
        const el = ref.current
        if (!el) return
        el.classList.add('ag-rows-in')
        window.setTimeout(() => el.classList.remove('ag-rows-in'), 1000)
    }, [reduce])
    return {ref, onFirstDataRendered}
}

Columns, editing & CSV export

  • Use flex for proportional widths and maxWidth to cap narrow columns.
  • Format values with valueFormatter; the shared currency formatter is (p) => `$${(p.value ?? 0).toLocaleString()}`.
  • Persisted-value i18n: raw values stay English in the data ('Admin', 'Active'); display goes through render-time key maps (ROLE_KEY/STATUS_KEYdemo: keys) in valueFormatters and renderers.
  • Editing: mark columns editable: true; constrain choices with agSelectCellEditor + values, numbers with agNumberCellEditor. Give an editable grid its own copy of the row array (useState(() => ROWS.map((r) => ({...r})))) so inline edits never mutate shared data.
  • CSV export (community-safe): capture the GridApi in onGridReady, then call api.exportDataAsCsv() from a toolbar Button.

To add a variant: add a grid component to grids.tsx (reuse useGridTheme + useRowStagger + ROWS), a page wrapper in DataGridVariants.tsx, a lazy appRoutes entry, a menu leaf under the Data Grid group, and the nav:/demo: i18n keys.

Best practices

  • Never add ag-grid-enterprise or enterprise-only features. Keep the registry on AllCommunityModule.
  • Lazy-load grid pages (AG Grid is heavy) — all six pages come from one module, so they share one Vite chunk.
  • Theme through tokens (useChartTokens + themeQuartz.withParams), reusing useGridTheme().
  • Bound the grid's height (style={{height}}) — AG Grid virtualizes and requires it.
  • Memoize columnDefs and the theme object so the grid isn't torn down each render.
  • Animate translate, not transform for any custom AG Grid row motion; gate it on useReducedMotion().
  • Custom renderers fill the cell (flex h-full items-center) and guard rowPinned.
  • Copy the row array before enabling editing so inline edits don't mutate shared seed data.
  • For a small, static dataset prefer the Basic Table — it's lighter and needs no grid lib.

Troubleshooting

SymptomCause & fix
Grid area is blank / zero heightThe wrapping element has no explicit height. Set style={{height}}.
Rows "jump" or mis-position during the entranceSomething is animating transform on .ag-row. Use the translate property instead.
Entrance replays while scrollingThe .ag-rows-in class wasn't removed. It must drop after ~1 s so recycled rows don't re-run the keyframe.
Grid colors don't match the active skin / dark modeThe theme was hardcoded instead of built from useChartTokens(). Use useGridTheme() (memoized on [tk]).
Custom cell content sits at the top of the rowWrap the renderer in flex h-full items-center.
Action buttons appear on the total rowRenderers/formatters must check p.node.rowPinned and bail on pinned rows.
Inline edits change other grids' dataThe editable grid shares the seed array. Give it its own copy (ROWS.map((r) => ({...r}))).
Console warning about modules not registeredModuleRegistry.registerModules([AllCommunityModule]) is missing or ran too late — call it at module scope, once.

FAQ

Why AG Grid Community and not a paid grid? It's MIT-licensed and ships cleanly to every ThemeForest buyer. CSV export is community — the Selection variant demos it.

Can I turn on row grouping / pivot / Excel export? No — those are enterprise-only. A buyer who needs them must license AG Grid Enterprise themselves.

Why six separate grid routes? Each variant demos one capability in isolation, which keeps every page focused and gives the sidebar's Data Grid group real depth. They share one source module, one dataset, one theme hook, and one Vite chunk — so the split costs nothing.

When should I use the Basic Table vs. the Data Grid? Small, mostly-read data → Basic Table (lighter, no grid lib). Larger, interactive data needing sort/filter/selection/editing/pinning → Data Grid.

Was this page helpful?