Skip to content

Table Customization

Use this reference when a ui.table needs presentation, controls, responsive behavior, or custom cell content while retaining Quasar QTable sorting, filtering, pagination, and selection behavior. Use editable tables instead when browser-originated cell values must be validated and committed by Python.

Version Baseline

This reference and its runnable example were verified against this bundled stack:

Layer Version Evidence
NiceGUI 3.16.0 Tagged Table source
Quasar 2.18.5 NiceGUI frontend dependencies
Vue 3.5.22 NiceGUI frontend dependencies

Recheck the tagged NiceGUI sources and bundled dependencies for another release. The current Quasar documentation may describe features added after NiceGUI's bundled Quasar version.

Choose The Narrowest Layer

Apply customization at the highest-level API that expresses it:

  1. Use ui.table(...) for rows, columns, defaults, stable row identity, title, selection, and pagination.
  2. Use column definitions for alignment, sorting, formatting, cell classes, header classes, and width hints.
  3. Use .props(...) for QTable behavior that the NiceGUI constructor does not expose, such as dense, separator, wrap-cells, rows-per-page-options, and empty-state labels.
  4. Use named slots for custom toolbar content, one special header or cell, loading, no-data content, or pagination controls.
  5. Use a full header, body, or item slot only when the whole generated structure must change.
  6. Add narrowly scoped CSS for behavior that neither component API covers, such as sticky columns.

NiceGUI's tagged table client wrapper passes props to QTable and forwards every supplied slot with its scoped properties. This makes QTable's API the source of truth below the NiceGUI wrapper, but it does not make every current QTable feature compatible with the bundled 2.18.5 release.

Columns Before Slots

A QTable column is both a data projection and a presentation contract. Keep its name unique because sorting and body-cell-[name] slot selection use it. field identifies or computes the raw cell value; it does not need to match the column name.

columns = [
    {
        "name": "available",
        "label": "In stock",
        "field": "stock",
        "sortable": True,
        "align": "right",
    },
]

table = ui.table(
    rows=rows,
    columns=columns,
    column_defaults={"headerClasses": "text-grey-8"},
    row_key="id",
)

Use plain keys such as classes, style, headerClasses, and headerStyle for static values. NiceGUI's client-side dynamic-property conversion recognizes colon-prefixed keys such as :field, :format, :sort, :classes, and :style as JavaScript expressions. Keep row data JSON-serializable; format display values in a column or slot rather than putting component objects in rows.

Raw Values And Cosmetic Display Formatting

Use the column's format(value, row) function when only the displayed cell text should change. QTable's tagged getCellValue resolves field first and then passes that raw value through format. The formatted result is used by the default cell renderer and exposed to body-cell-* slots as props.value.

Keep the layers distinct:

Layer Owns Effect
row value canonical JSON-serializable data sent by Python remains available as props.row.<field>
field raw value extraction or derivation supplies sorting and the input to format
format cosmetic text projection changes default rendering, props.value, and local default-filter matching
body-cell-* slot component structure around the value use for badges, icons, links, controls, or multiple elements

Formatting does not mutate table.rows or the row object. QTable's tagged sorting implementation compares raw field values, while its tagged default filter searches the formatted values returned by getCellValue. This is usually desirable: numbers sort numerically while users can search what they see.

Prefix And Suffix Text

Use a null-safe :format expression for simple prefix or suffix text:

columns = [
    {
        "name": "price",
        "label": "Unit price",
        "field": "price",
        "sortable": True,
        ":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
    },
    {
        "name": "stock",
        "label": "In stock",
        "field": "stock",
        "sortable": True,
        ":format": "value => value == null ? '' : `${value} units`",
    },
]

The underlying values stay numeric, so sorting remains numeric. Check value == null rather than if (!value) when zero is valid; 0 must render as $0.00 or 0 units, not as an empty cell. Use a named cell slot instead when the prefix or suffix needs separate styling, an icon, a tooltip, or accessible text that differs from the visible text.

Datetime Text

Send datetimes as ISO 8601 strings with an explicit offset. Python's datetime.isoformat() includes an offset for aware values, and astimezone() preserves the represented instant while converting zones:

from datetime import UTC, datetime

updated_at = datetime.now(UTC)
row = {"updated_at": updated_at.astimezone(UTC).isoformat()}

Avoid locale-formatted strings and naive datetime strings as transport values. JavaScript guarantees support for its standard ISO date-time format, but date-time strings without an offset are interpreted in the browser's local timezone when they contain both a date and time. An explicit Z or +00:00 identifies the instant unambiguously.

For repeated cells, construct one Intl.DateTimeFormat and return a formatter closure. NiceGUI evaluates the colon-prefixed expression into the column function, so the formatter is reused instead of performing locale-data lookup for every cell:

columns = [
    {
        "name": "updated_at",
        "label": "Updated",
        "field": "updated_at",
        "sortable": True,
        ":sort": "(left, right) => Date.parse(left) - Date.parse(right)",
        ":format": """(() => {
            const formatter = new Intl.DateTimeFormat('en-US', {
                dateStyle: 'medium',
                timeStyle: 'short',
                timeZone: 'UTC',
            });
            return value => {
                if (!value) return '';
                const timestamp = Date.parse(value);
                return Number.isNaN(timestamp) ? 'Invalid date' : formatter.format(timestamp);
            };
        })()""",
    },
]

Choose locale and timezone deliberately:

  • Use a fixed locale such as en-US when the product requires one stable language convention; use undefined to follow the browser's locale.
  • Set timeZone to UTC or an IANA zone such as America/New_York for deterministic product behavior; omit it only when each viewer should see browser-local time.
  • Keep invalid-value handling explicit. Date.parse() returns NaN for an invalid value, while passing an invalid date directly to the formatter can throw.

Sorting still receives the raw ISO value from field; it never compares the formatted label. The explicit :sort function above converts those raw strings to epoch milliseconds, so values with different offsets are ordered by instant while cells keep their localized display text. Validate datetime strings before sending them because an invalid value makes Date.parse() return NaN. For a simpler data contract, send epoch milliseconds as the field value and format that number directly.

Lexicographic sorting without a custom sort function is chronological only when every ISO string uses the same fixed-width representation and offset, as in normalized UTC values. Local filtering is intentionally different: it matches the formatted text, so searches follow the chosen locale and timezone rather than the raw ISO string.

Cell Classes And Styles

Quasar's tagged table-column-selection.js, QTh.js, and QTd.js establish the exact targets:

Column key Applied to Static or dynamic Typical uses
headerClasses The generated <th> for this column Static string Header color, weight, whitespace, sticky positioning, utility-based width
classes Every generated <td> in this column String or function of row Body typography, whitespace, conditional color, utility-based width
headerStyle Inline style on the generated <th> Static string Header-specific dimensions or positioning
style Inline style on every generated <td> String or function of row Body dimensions, overflow, or row-dependent presentation

align is separate: Quasar prepends text-left, text-right, or text-center to both header and body classes. It also appends header state classes such as sortable, sorted, and sort-desc. Supplying classes or headerClasses does not remove those generated classes.

These fields interact through normal CSS rules:

  • On the same cell, inline style normally wins over a conflicting class declaration. A class rule containing !important can beat a normal inline declaration; avoid building a width policy around that exception.
  • The order of class names in the class attribute does not decide precedence. CSS origin, importance, cascade layer, selector specificity, and stylesheet source order do.
  • headerClasses never flows into body cells, and classes never flows into the header. The same separation applies to headerStyle and style.
  • column_defaults is merged as {**defaults, **column}. A column-level value replaces the complete default value for that key; class strings are not concatenated. Include shared classes again in an overriding column value when they must be retained.
  • A named header-cell-* or body-cell-* slot replaces QTable's default cell renderer. Use table.header(column_name) or table.cell(column_name) so Quasar reapplies the computed column classes and styles to the resulting QTh or QTd.

Prefer classes for reusable visual policy and static utility classes. Prefer headerStyle and style for one-off values, especially dimensions that do not have a clear project utility. Use :classes or :style only when the value genuinely depends on the browser-side row; otherwise a static value is easier to inspect and override.

Column Width Model

QTable renders a native table with width: 100%, max-width: 100%, and, by default, the browser's table-layout: auto algorithm. One column is a shared track: its header and all body cells receive the same final used width even though each cell can contribute a different width constraint.

This has several consequences:

  1. A width in headerStyle and a different width in style do not override each other because they are declarations on different elements. The browser considers both, along with every cell's min-content and max-content size, and computes one column width.
  2. Under automatic layout, width is a strong sizing input, not a guaranteed cap. Long unbreakable content, cell padding, other columns, and the table's available width can make the column wider.
  3. min-width supplies a floor. max-width alone is not a dependable truncation mechanism for an automatic table because intrinsic content still participates in track sizing.
  4. QTable is nowrap by default through q-table--no-wrap. The wrap-cells prop removes that rule, allowing ordinary wrapping and reducing columns toward their min-content widths. For IDs or URLs, add overflow-wrap: anywhere to create breaks inside otherwise unbreakable strings.
  5. Cell padding contributes to the track width. QTable uses 16px horizontal padding per side normally and 8px in dense mode, with larger edge padding retained for the first and last columns.

Use the following mechanisms in order:

Goal Recommended mechanism
Let content choose sensible widths Leave style and headerStyle unset; keep automatic layout
Keep a column from becoming too narrow Put the same absolute min-width in style and headerStyle
Give columns proportional targets Put matching percentage width values in style and headerStyle; treat them as targets under automatic layout
Keep utility or action cells compact Use auto-width on table.header(...) and table.cell(...) in slots, or Quasar's q-table--col-auto-width class on both header and body; it sets width: 1px and content supplies the floor
Allow readable narrow layouts Enable wrap-cells, set a practical min-width for key columns, and let QTable's middle container scroll horizontally when the total minimum exceeds the viewport
Guarantee a column allocation Use fixed table layout, an explicit table width, matching first-row/header widths, and an explicit overflow policy
Truncate text Use a fixed/constrained track plus a block wrapper with Tailwind's truncate utility; text-overflow does not create overflow by itself

For ordinary data tables, automatic layout plus a few minimum or target widths is the idiomatic default. Apply matching values to headerStyle and style: one declaration is often enough to influence the shared track, but matching declarations make intent explicit, survive empty datasets, and keep custom header/body renderers consistent.

Use fixed layout only when predictable allocation matters more than content-driven sizing. QTable's table-style prop styles the scrolling wrapper <div>, not the nested native <table>, so it cannot set table-layout. This nested element cannot receive a utility class through NiceGUI or QTable's public API, making one application class and one scoped CSS rule necessary. Keep truncation on a Python-created wrapper with Tailwind's truncate utility:

ui.add_css("""
.inventory-table .q-table {
    table-layout: fixed;
}
""")

table = ui.table(rows=rows, columns=columns).classes("inventory-table w-full")

with table.add_slot("body-cell-name"), table.cell("name"):
    ui.element("span").props(':textContent="props.value"').classes("block truncate")

With fixed layout, the table must have a non-automatic width; QTable already gives its native table width: 100%, while w-full constrains the NiceGUI element. The first row's explicit widths determine tracks without a <colgroup>; later body content does not resize them and therefore needs wrapping, clipping, or scrolling. A full custom header changes that first-row contract, so retest every column after introducing one.

Column visibility has two useful patterns:

  • Mark identity or action columns required when they must remain visible.
  • For a Python-owned column picker, assign the selected column names to table.props["visible-columns"], then call table.update(). QTable automatically includes columns marked required. Assign the Python list directly to the prop rather than interpolating it into a JavaScript expression.

QTable Props

Pass static QTable props as whitespace-delimited tokens. Prefix a prop with : only when its value is a JavaScript expression:

table.props(
    'flat bordered separator=horizontal wrap-cells '
    ':dense="Quasar.Screen.lt.md" '
    ':rows-per-page-options="[5, 10, 0]"'
)

Use static props for fixed component policy and dynamic props for small browser-owned presentation decisions such as responsive density. Keep authoritative business state and permission decisions in Python.

Useful QTable presentation props include:

Need Props
Surface flat, bordered, square, dark, color
Cell layout dense, separator, wrap-cells
Layers hide-header, hide-bottom, hide-pagination, hide-no-data
Labels no-data-label, no-results-label, loading-label, rows-per-page-label
Paging and sorting rows-per-page-options, binary-state-sort, column-sort-order
Large local datasets virtual-scroll, virtual-scroll-item-size, virtual-scroll-sticky-size-start

Virtual scrolling needs a bounded height and accurate row-size assumptions. If a full body slot renders multiple QTr elements for one data row, follow Quasar's q-virtual-scroll--with-prev and unique-key requirements. Do not enable virtual scrolling as a default for a small table.

Named Slots

Prefer the smallest QTable slot that owns the customization:

Slot Use
top-left, top-right Title, filters, column controls, export
header-cell-[name] One custom header while preserving other generated headers
body-cell-[name] One custom cell type while preserving generated rows and other cells
no-data, loading Empty, filtered-empty, and busy states
pagination Custom page controls
footer A real table footer such as totals

Since NiceGUI 3.5.0, a scoped slot can contain NiceGUI elements. Follow the Python-owned slot composition rule: use context managers and NiceGUI elements for structure, keep application logic in Python, and reserve dynamic props for scoped values that exist only in the browser. Wrap body-cell content in table.cell(column_name) so QTable retains the column's alignment and cell semantics:

STATUS_COLORS = {
    "Ready": "positive",
    "Low": "warning",
    "Backorder": "negative",
}

columns = [
    {
        "name": "status",
        "label": "Status",
        "field": "status",
        "colorByValue": STATUS_COLORS,
    },
]

with table.add_slot("body-cell-status"), table.cell("status"):
    ui.badge().props(
        ':label="props.value" '
        ':color="props.col.colorByValue[props.value] ?? \'grey\'"'
    )

Inside table slots, props.value is the parsed and formatted cell value, props.row is the row object, and props.col is the column definition. Custom JSON-serializable column keys such as colorByValue therefore provide a clean bridge from Python-owned display policy to a reused browser-side slot. Use Quasar color names in the mapping when the component's color prop should follow the active theme, and include a fallback for unexpected values.

A slot template is reused for every matching row, so props.value, props.row, and props.col are JavaScript expressions, not Python variables. Keep authorization and business-state decisions out of this mapping; it is client-visible presentation metadata.

Add A Button To A Cell

Add an action column to the table's column definitions, then target it with the corresponding body-cell-[name] slot. Use a NiceGUI ui.button rather than writing a raw QBtn template, and wrap it in table.cell(column_name) so QTable preserves the cell's alignment, classes, styles, and table semantics.

The slot's props.key is the primitive identity derived from the table's row_key. Forward that key to Python instead of sending the full browser-side row object. Resolve the current authoritative row again in the handler because the record may have changed or disappeared since the table was rendered:

from nicegui import events
from nicegui import ui

columns = [
    {"name": "actions", "label": "Actions", "required": True, "align": "center"},
    {"name": "name", "label": "Product", "field": "name", "align": "left"},
]
rows = [
    {"id": 101, "name": "Desk lamp"},
    {"id": 102, "name": "Task chair"},
]

table = ui.table(rows=rows, columns=columns, row_key="id")

def open_product(event: events.GenericEventArguments) -> None:
    product = next((row for row in table.rows if row[table.row_key] == event.args), None)
    if product is None:
        ui.notify("Product no longer exists", type="negative")
        return
    ui.notify(f"Opening {product['name']}")

with (
    table.add_slot("body-cell-actions"),
    table.cell("actions"),
    ui.button(icon="open_in_new")
    .props("round flat size='sm'")
    .on(
        "click.stop",
        handler=open_product,
        js_handler="() => emit(props.key)",
    ),
):
    ui.tooltip("Open product")

The compact icon-only button uses QBtn's round, flat, and size props to avoid a visually heavy filled action in every row. Nest ui.tooltip inside the button so each row's cloned QTooltip uses its own parent as the target. Do not call action_button.tooltip(...) in a reused scoped slot: NiceGUI implements that convenience method with an element-ID target, and every clone would resolve to the first button. The .on(...) bridge is necessary here because props.key exists only in the reused browser-side slot scope; a normal Python on_click callback cannot capture a different row for each rendered instance. The click.stop modifier prevents the button click from also reaching a row-click handler. Treat the key as untrusted input and repeat authorization, existence, and state checks before performing the real action.

Replacing the full body or header slot also replaces behavior QTable would otherwise generate. Render QTr plus QTd or QTh, pass the scoped props through, preserve unique row keys, and retest sorting, selection, focus, and responsive behavior. In particular, QTable row-click events are not emitted when a full body, row, or item slot owns the structure.

NiceGUI controls can live in QTable toolbar slots. Bind an input's value one way to the table's filter property for a small, local dataset:

with table.add_slot("top-right"):
    ui.input(placeholder="Search inventory").props(
        "dense outlined clearable debounce=250"
    ).bind_value_to(table, "filter")

The path from keystroke to displayed rows is:

  1. debounce=250 waits until input has been idle for 250 milliseconds, reducing value-change traffic while the user types.
  2. bind_value_to(table, "filter") immediately initializes table.filter from the input and then propagates later input values in that direction only.
  3. For local rows, QTable lowercases the search term and each computed cell value. A row remains when at least one column value contains the term as a substring.
  4. QTable filters first, sorts the matching rows, resets pagination to page 1 when the filter changes, and then slices the current page.
  5. When no row matches, the no-data slot receives the configured no-results-label as message and a truthy filter; when the underlying row list is empty without a filter, it receives no-data-label and a falsy filter.

The default matcher uses each column's resolved field and then its format function. In this example, searches can therefore match displayed values such as Hardware, Backorder, or $31.25; the row ID is not searchable because it has no column. QTable passes its computed columns to the matcher, so a column excluded with the visible-columns prop is not searched. Hiding a column with CSS classes would leave it in the search set and should not be used as a substitute for the component prop.

This is client-side filtering over rows already sent to the browser. Do not load a large dataset merely to search it locally. When pagination contains rowsNumber, QTable switches to server-side mode, stops applying its local matcher, and emits a request carrying the filter and pagination state; validate the term and query the authoritative data source in that handler. Add explicit backend search semantics for field scope, tokenization, locale, and ranking instead of assuming they match QTable's substring behavior.

When Python needs the browser's current result set, prefer await table.get_filtered_sorted_rows() for all matches before pagination or await table.get_computed_rows() for the current page. These helpers require a connected client and should not replace backend filtering for server-owned data.

Prefer typed table helpers over raw frontend calls. Use table.run_method(...) only for QTable methods without a NiceGUI helper, such as scrollTo, sort, or firstPage, and verify the method in the bundled QTable API first.

Runnable Example

The complete example combines raw-value sorting with cosmetic prefix, suffix, and datetime formatting; column defaults; responsive QTable props; filtering; column visibility; a toolbar; custom status and action cells; and a filtered-empty state. It is available as table_customization.py and as skill://nicegui/examples/table_customization.py.

table_customization.py

Run it from the repository root:

uv run src/jsl_mcp/docs/skills/nicegui/examples/table_customization.py

Verify that stock and price sort by their numeric row values while displaying suffix and prefix text. Verify that updated timestamps sort chronologically by their raw ISO values while displaying localized UTC text. Confirm that searches are case-insensitive, match formatted values across columns, reset to the first page, and change the empty-state message when no rows match. Confirm that optional columns can be hidden and restored, the status cell retains its alignment and badge styling, and each action button reports the product from its own row. Hover action buttons in multiple rows and confirm that each one independently shows exactly one Open product tooltip. Resize the browser across mobile, landscape desktop, and portrait desktop widths; the toolbar must remain usable and the table must scroll without overlapping controls.

Escalation Boundaries

Source Index

Completion Check

Before accepting a customized table:

  1. Verify the target NiceGUI release and its bundled Quasar version.
  2. Set a primitive, immutable, unique row_key before using selection or row-scoped actions.
  3. Use constructor arguments and column definitions before QTable props or slots.
  4. Keep sortable values canonical and serializable; use format only for cosmetic text and sort only when raw values need a custom comparator.
  5. Choose the narrowest named slot and preserve QTable cell or header semantics.
  6. Recheck sorting, filtering, pagination, selection, and empty states after customization.
  7. Check mobile, landscape desktop, and portrait desktop layouts; verify the toolbar remains usable and wide tables scroll without overlapping controls.