Skip to content

Customize the UI

The built-in renderer is polished by default and still has a deliberate escape ladder. Start with tokens, then use class operations or semantic parts, then regions or catalogs, and choose a custom renderer only when the application owns the complete Table structure. Customization is data passed to React—not a global mutable registry.

Most applications should import the complete maintained default once:

import '@inertiax/react/styles.css';

It is compiled and scoped. It does not need Tailwind in the application or a package-source scan.

The maintained stylesheet includes InertiaX-owned selects for Boolean, option, multi-option, and page-size choices; calendar controls for date and local datetime filters; a shared 24-hour time field for time and datetime; deliberate focus rings; and copy-action tooltips.

These are renderer defaults, not protocol requirements. Date values remain YYYY-MM-DD, local datetime values remain YYYY-MM-DDTHH:mm[:ss[.fraction]] with one to six fractional digits, and select values retain their original string, number, or Boolean type. The time field accepts HH:mm[:ss[.fraction]]; incomplete typing stays local, and a date-only datetime draft does not emit until its time is complete. Laravel keeps the accepted value exact across the request round trip, including the T separator and omitted seconds. Basic filters still apply through the Table session’s debounce; advanced filters remain a draft until Apply filters, and ranges retain their explicit apply action.

For a fully independent visual treatment, import only the functional foundation:

import '@inertiax/react/base.css';

With base.css, the application owns the complete visual treatment. InertiaX retains only accessibility helpers, focus visibility, table overflow, portal safety, and reduced-motion behavior. The application must style the documented semantic parts and all visible states itself. Do not import both files: styles.css already includes the base layer.

Application-wide customization belongs on the provider:

<InertiaXProvider
table={{
texts: { refresh: { label: 'Reload' } },
theme: { classNames: { root: { add: 'my-table' } } },
}}
>
{children}
</InertiaXProvider>

One-Table customization belongs on the renderer:

<InertiaX
prop="users"
table={{ texts: { empty: { label: 'No team members yet.' } } }}
/>

Resolution is built-in → provider → component. Inputs are snapshotted, so component operations do not mutate a sibling Table or another provider root.

Tokens are the first choice for a cohesive visual change. They affect built-in regions and the Table-owned columns popover without changing markup or behavior:

const applicationTable = {
theme: {
tokens: {
primary: '#166534',
ring: '#16a34a',
controlRadius: '0.4rem',
tableRadius: '0.85rem',
cellPaddingY: '0.7rem',
},
},
} satisfies TableCustomizationOptions;
<InertiaXProvider table={applicationTable}>{children}</InertiaXProvider>;

A component token replaces the provider value for that key and inherits every unspecified key:

<InertiaX prop="users" table={{ theme: { tokens: { primary: '#1d4ed8' } } }} />

The finite token set covers surfaces, foregrounds, accent/primary/destructive states, borders, focus ring, radii, control height, cell spacing, header/row/state/skeleton colors, font, and shadow.

Keep dynamic customization stable when it matters

Section titled “Keep dynamic customization stable when it matters”

The renderer uses ordinary React prop identity. Inline objects and callbacks are valid and always produce correct output, but a new reference tells React that the customization changed. If the page also owns unrelated local state, keep static customization at module scope:

const usersTable = {
texts: { empty: { label: 'No team members yet.' } },
};
<InertiaX prop="users" table={usersTable} />;

For customization derived from state, use React’s normal useMemo and useCallback tools at the page boundary:

const openUser = useCallback((id: string | number) => router.visit(`/users/${id}`), []);
const table = useMemo(
() => ({
regions: { beforeToolbar: TeamSummary },
events: { selectionChange: ({ selection }) => openUser(selection[0]!) },
}),
[openUser],
);
<InertiaX prop="users" table={table} />;

This is an optional render optimization, not a correctness requirement. Keep a fresh object or callback when it intentionally captures fresh values.

Every finite Table region has a theme key, including root, toolbar, filters, tableWrapper, table, headers, body rows/cells, state rows, footer, pagination, selectionSummary, cell, and filterGroup.

Use a string to append classes, or an explicit operation:

table={{
theme: {
classNames: {
root: { add: 'dashboard-table', remove: 'space-y-4' },
tableWrapper: { replace: 'overflow-hidden rounded-xl border' },
tableBodyRow: 'hover:bg-neutral-50',
},
},
}}
  • add appends classes.
  • remove removes one class or a list.
  • replace replaces the complete class value for that key.

Use replace sparingly: it assumes responsibility for all layout behavior on that region.

Use data-inertiax-part when a visual decision is more precise than a token or named class region. Scope selectors beneath a class you add to this Table:

const auditTable = {
theme: { classNames: { root: { add: 'audit-table' } } },
} satisfies TableCustomizationOptions;
<InertiaX prop="audit" table={auditTable} />;
.audit-table [data-inertiax-part='table-header-cell'] {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.audit-table [data-inertiax-part='table-row'][data-state='selected'] {
background: color-mix(in oklab, var(--inx-table-primary) 12%, transparent);
}

data-inertiax-part is the stable styling vocabulary. data-slot, generated utility classes, and the internal DOM depth are implementation details. A portaled part such as columns-popover is not a descendant of the Table root, so target that documented part directly when an alternate skin needs rules beyond the propagated tokens.

Override only the messages your application owns:

table={{
texts: {
search: { placeholder: 'Search team members…' },
empty: { label: 'No matching team members.' },
pagination: { pageSummary: '{page} / {lastPage}' },
boolean: { true: 'Enabled', false: 'Disabled' },
},
}}

Text groups cover search, columns, sorting, filters, Clause labels, booleans, refresh, loading, empty/error states, selection, pagination, and copying. Placeholders such as {page}, {lastPage}, {selected}, {total}, and {column} are resolved by the built-in UI where applicable.

Replace a bounded part of the standard UI without rebuilding the Table:

function EmptyState({ colSpan }: TableStateRegionProps) {
return (
<tr>
<td colSpan={colSpan}>Invite the first team member.</td>
</tr>
);
}
<InertiaX
prop="users"
table={{
regions: {
beforeToolbar: TeamSummary,
emptyState: EmptyState,
pagination: CompactPagination,
},
}}
/>

Available regions are beforeToolbar, toolbar, afterToolbar, tableBodyWrapper, loadingState, errorState, emptyState, footer, and pagination. Region props expose the component definition, controller, canonical state, request status, and TanStack Table projection.

Register namespaced server types through catalog operations:

<InertiaX
prop="users"
table={{
cellRenderers: [
{ operation: 'register', key: 'acme/role', value: RoleCell },
],
filterInputs: [
{ operation: 'register', key: 'acme/role-filter', value: RoleFilterInput },
],
}}
/>

Use register for a new key and replace only for an existing key. Duplicate registration and a missing replacement fail with context. Built-in keys are stable product behavior; prefer a namespaced custom type to globally replacing a built-in unless you intentionally own that change.

See Custom types for the matching Laravel half.

The same catalog can replace a built-in control when native behavior or an application design system is preferable. This example deliberately takes ownership of the string-select value:

import type { TableFilterInputRendererProps } from '@inertiax/react';
function NativeSelectInput({ ariaLabel, definition, id, onChange, value }: TableFilterInputRendererProps) {
return (
<select
aria-label={ariaLabel}
id={id}
onChange={(event) => onChange(event.target.value || undefined)}
value={typeof value === 'string' ? value : ''}
>
<option value="">Any</option>
{definition.options?.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
);
}
<InertiaX
prop="users"
table={{
filterInputs: [
{ operation: 'replace', key: 'select', value: NativeSelectInput },
],
}}
/>

For non-string options, the replacement must preserve the declared JSON primitive identity just as the built-in control does. Replace one catalog leaf for a control family; use a region or complete renderer only when the application owns a larger interaction boundary.

Custom children are the last step when regions and leaf catalogs are no longer enough. They retain the same Table session, controller, state, status, and request lifecycle, but the application owns all rendered structure:

<InertiaX prop="users">
{(session) => <UsersGrid session={session} />}
</InertiaX>

Use this for a genuinely different renderer, not to change spacing or replace one cell.

A reusable skin is compiled CSS plus an ordinary TableCustomizationOptions preset. Export the CSS and the stable preset from your own design-system package, import @inertiax/react/base.css once, and pass the preset at provider or component scope. There is no runtime skin registry and no hidden plugin lifecycle.

Use styles.css plus tokens and theme class operations for color, spacing, borders, and typography. Use semantic parts for precise CSS, structural regions when markup or behavior must change, and a catalog renderer when one protocol type needs new content. This keeps customization local and avoids replacing the entire Table for a small visual difference.