Skip to content

Data Table

<quiet-data-table> experimental since 6.0

Displays tabular data with sorting, filtering, selection, pagination, and more. Assign rows to the data property and, optionally, refine columns with the columns property.

Built on TanStack Table , the data table covers everyday needs without the complexity of a full spreadsheet grid. It works with plain arrays of objects and infers columns when you don't define any. Everything runs in the browser by default and rows virtualize automatically once the body scrolls. When the data is too large to load at once, manual mode hands sorting, filtering, and pagination to your server.

<quiet-data-table
  label="Portfolio"
  data-quiet-preload="quiet-sparkline"
  page-size="10"
  resizable
  reorderable
  with-search
  with-column-toggle
  with-column-menu
  paginate
  id="data-table__finance"
></quiet-data-table>

<style>
  #data-table__finance {
    --row-height: 3.25rem;
  }
</style>

<script>
  {
    const grid = document.getElementById('data-table__finance');

    const palette = ['#2d6cdf', '#e8833a', '#3aa564', '#7a52d6', '#d64f6e', '#2aa3b8', '#c79a2e', '#5566d6'];
    const instruments = [
      ['KITT10', 'Kingdom of Kittania 10-Year Bond', 'Bond'],
      ['TABY30', 'Tabbyland 30-Year Government Bond', 'Bond'],
      ['NAPF', 'NapTime National Loaf Fund', 'ETF'],
      ['NIP-USD', 'Catnip Coin', 'Crypto'],
      ['WHSK', 'Whiskerworks Inc.', 'Stock'],
      ['MITT27', 'Mittensburg Government Bond 2027', 'Bond'],
      ['PURR', 'Purrfect Industries', 'Stock'],
      ['MEOW', 'Meowtric Corporation', 'Stock'],
      ['ZOOM', 'Zoomies Logistics, Inc.', 'Stock'],
      ['MEW-USD', 'Mewcoin', 'Crypto'],
      ['CATP', 'Catnip Total Market ETF', 'ETF'],
      ['MOUS', 'Mouser Robotics & Co.', 'Stock'],
      ['FURR10', 'Furrance 10-Year Bond', 'Bond'],
      ['PAWS-USD', 'Pawcoin', 'Crypto'],
      ['TUNA', 'Tuna Time Foods, Inc.', 'Stock'],
      ['PAWG30', 'Pawgistan 30-Year Gilt', 'Bond'],
      ['CLAW', 'Clawmark Growth ETF', 'ETF'],
      ['NAPP', 'NapPod Holdings', 'Stock'],
      ['WHIS10', 'Whiskertopia 10-Year Bond', 'Bond'],
      ['HISS-USD', 'Hisscoin', 'Crypto'],
      ['LOAF', 'Loaf & Company', 'Stock'],
      ['FLUF', 'Fluffball Dividend ETF', 'ETF'],
      ['LITT10', 'Litteria 10-Year Bond', 'Bond'],
      ['SCRT', 'Scratchpost Manufacturing', 'Stock'],
      ['YARN-USD', 'Yarncoin', 'Crypto'],
      ['PURX', 'Purrindex 500 ETF', 'ETF'],
      ['FELV', 'Feline Ventures, Inc.', 'Stock'],
      ['CALI30', 'Calicoast 30-Year Bond', 'Bond'],
      ['TOEB-USD', 'Toebean', 'Crypto'],
      ['CATX', 'Catropolis Holdings', 'Stock']
    ];

    const rand = (min, max) => min + Math.random() * (max - min);
    const spark = () => Array.from({ length: 22 }, () => Math.round(rand(14, 100)));
    const colorFor = sym => palette[[...sym].reduce((sum, c) => sum + c.charCodeAt(0), 0) % palette.length];

    grid.data = Array.from({ length: 134 }, (unused, i) => {
      const [symbol, name, type] = instruments[i % instruments.length];
      return {
        id: i + 1,
        symbol,
        name,
        type,
        color: colorFor(symbol + i),
        timeline: spark(),
        pnl: rand(-400, 400),
        totalDelta: rand(-35000, 35000),
        totalValue: rand(500, 50000)
      };
    });

    const fmt = n => Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    const trendColor = up =>
      up ? 'var(--quiet-constructive-text-colorful)' : 'var(--quiet-destructive-text-colorful)';
    // P&L is a single signed figure (a gain or a loss), so sorting ranks losses below gains
    const pnlCell = value => {
      const up = value >= 0;
      return `<span style="color: ${trendColor(up)}; font-variant-numeric: tabular-nums;">${up ? '+' : '−'}$${fmt(value)}</span>`;
    };
    // Total Value leads with the (always positive) holding value being sorted, with the day's change as a trailing badge
    const valueCell = (value, change) => {
      const up = change >= 0;
      return `<div style="display: flex; align-items: center; justify-content: flex-end; gap: 0.5rem; font-variant-numeric: tabular-nums;">
        $${fmt(value)}
        <span style="color: ${trendColor(up)}; font-size: 0.8375em;">${up ? '↑' : '↓'}${fmt(change)}</span>
      </div>`;
    };

    grid.columns = [
      {
        field: 'symbol',
        label: 'Ticker',
        pinned: 'start',
        width: 300,
        filterable: true,
        filterType: 'text',
        render: (value, row) =>
          `<div style="display: flex; align-items: center; gap: 0.625rem; min-width: 0;">
            <span style="display: inline-flex; align-items: center; justify-content: center; width: 1.875rem; height: 1.875rem; border-radius: 50%; background: ${row.color}; color: #fff; font-size: 0.5625rem; font-weight: 700; flex: 0 0 auto;">${row.symbol.replace(/[^A-Z0-9]/g, '').slice(0, 4)}</span>
            <span style="font-weight: 700; flex: 0 0 auto;">${row.symbol}</span>
            <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--quiet-text-muted);">${row.name}</span>
          </div>`
      },
      {
        field: 'timeline',
        label: 'Timeline',
        sortable: false,
        truncate: false,
        minWidth: 180,
        flex: 1,
        render: (value, row) =>
          `<quiet-sparkline label="${row.symbol} price timeline" data="${value.join(' ')}" appearance="solid" style="height: 1.75rem; --fill-color: color-mix(in oklab, ${row.color}, transparent 85%); --line-color: ${row.color}; --line-width: 2px;"></quiet-sparkline>`
      },
      {
        field: 'pnl',
        label: 'P&L',
        align: 'end',
        minWidth: 120,
        flex: 1,
        filterable: true,
        filterType: 'number',
        render: value => pnlCell(value)
      },
      {
        field: 'totalValue',
        label: 'Total Value',
        align: 'end',
        minWidth: 220,
        flex: 2,
        filterable: true,
        filterType: 'number',
        render: (value, row) => valueCell(value, row.totalDelta)
      }
    ];
  }
</script>

Examples Jump to heading

Providing data Jump to heading

Assign an array of objects to the data property. When you don't supply columns, the data table infers one column per key in the first row and humanizes the field name for the header (so lastSeen becomes "Last Seen").

<quiet-data-table id="data-table__data"></quiet-data-table>

<script>
  document.getElementById('data-table__data').data = [
    { name: 'Whiskers', breed: 'Tabby', favoriteSpot: 'Sunny windowsill', lastSeen: '2 hours ago' },
    { name: 'Mittens', breed: 'Calico', favoriteSpot: 'Top of the fridge', lastSeen: 'Yesterday' },
    { name: 'Shadow', breed: 'Bombay', favoriteSpot: 'Under the couch', lastSeen: '3 days ago' }
  ];
</script>

For control over labels, alignment, rendering, and which columns appear, provide a columns array instead. Each column's field maps to a key in your row objects, and the rest of the column options are optional.

<quiet-data-table label="Products" id="data-table__columns"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__columns');

  grid.columns = [
    { field: 'sku', label: 'SKU', flex: 1 },
    { field: 'name', label: 'Product', flex: 2 },
    { field: 'price', label: 'Price', align: 'end', flex: 1, render: value => `$${value.toFixed(2)}` },
    { field: 'stock', label: 'In stock', align: 'end', flex: 1 }
  ];

  grid.data = [
    { sku: 'A-100', name: 'Widget', price: 9.99, stock: 142 },
    { sku: 'B-220', name: 'Gadget', price: 19.95, stock: 0 }
  ];
</script>

Column options Jump to heading

Each entry in the columns array is a column definition. Only field is required and everything else falls back to a sensible default. These options aren't part of the generated API reference below, so the full set is documented here.

Option Type Description
field string Required. The key in each row object this column reads from.
label string The header text. Defaults to a humanized version of field.
align 'start' | 'center' | 'end' Text alignment for the column's header and cells. Defaults to start.
truncate 'start' | 'center' | 'end' | false Where a plain-text cell clips when it overflows. end (the default) is a trailing CSS ellipsis; start and center keep both ends visible via <quiet-truncate>. The positional values are ignored when render is set. false opts out of truncation so the cell wraps to fit its content.
sortable boolean Whether the column can be sorted. Defaults to true.
invertSort boolean Flips the column's ascending and descending order. Handy for rank-like columns where lower is "better". Defaults to false.
sortNulls 'first' | 'last' Where empty (null/undefined) values sort, regardless of direction. Defaults to last.
searchable boolean Whether the column participates in the global search (the query). Defaults to true.
filterable boolean Whether the column has a filter button in its header. Defaults to false.
filterType 'set' | 'text' | 'number' | 'range' | 'date-range' The kind of filter shown. set (the default) is a searchable checklist of the column's unique values; text and number are a single input that matches values containing what's typed; range is a pair of min/max number inputs that keep rows within the bounds; date-range is a pair of from/to date inputs that keep rows within the chosen days.
filterOperator 'contains' | 'startsWith' | 'endsWith' | 'equals' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' How a text or number filter matches what's typed. The string operators are case-insensitive; the comparison operators compare numerically when both sides are numbers. Ignored by other filter types and when filter is set. Defaults to contains.
filterPlaceholder string Placeholder text for a text, number, or set filter input. Defaults to a localized "Filter".
filterPlaceholderMin string Placeholder text for a range or date-range filter's minimum input. A range defaults to the column's current lowest value (falling back to a localized "Min"); a date range defaults to a localized "Start".
filterPlaceholderMax string Placeholder text for a range or date-range filter's maximum input. A range defaults to the column's current highest value (falling back to a localized "Max"); a date range defaults to a localized "End".
hideable boolean Whether the column can be hidden via the column toggle or menu. Defaults to true.
hidden boolean Whether the column starts hidden. Defaults to false.
resizable boolean Whether the column can be resized. Defaults to the table's resizable property.
pinned 'start' | 'end' Pins the column to the start or end while scrolling horizontally.
width number An initial width in pixels. Required for pinned columns to position correctly.
flex number Lets the column grow to fill leftover width, CSS flex-grow-style. 0 (the default) keeps it fixed; a positive number stretches it, sharing space by ratio. Floors at the content width (or minWidth), caps at maxWidth. Ignored with an explicit width or pinned; dropped on hand-resize. See column sizing.
minWidth number The minimum width in pixels when resizing or auto-sizing. Defaults to 60.
maxWidth number Caps how wide a column auto-sizes to fit its content. When set, also limits how far it can be resized. An explicit width is never capped. Defaults to 400.
render (value, row) => string | Node | TemplateResult Renders custom cell content. Receives the cell's value and the full row. Strings are rendered as HTML, so only use trusted content.
cellStyle (value, row, column) => object | null Returns an inline style object (keyed by CSS property) for this column's cells. Receives the cell's value, the full row, and the column. Return a falsy value to leave cells unstyled. See conditional styling.
sort (a, b) => number A custom comparator for sorting, like Array.prototype.sort . Receives two cell values.
filter (value, query, row) => boolean A custom matcher for the column's text/number filter. Receives the cell's value, the typed query, and the full row, and returns whether the row passes. See column filters.
exportValue (value, row) => string | number | boolean | null | undefined Overrides the value written for this column when exporting to CSV. Falls back to the raw value when omitted.
aggregate 'sum' | 'avg' | 'min' | 'max' | 'count' | 'unique' | (values, rows) => string | number Summarizes the column's values in the footer row, and on group rows when grouping via groupBy. Built-ins run over the filtered rows across every page.
footer string | (info) => string | Node | TemplateResult Renders the column's footer cell, overriding the aggregate display. A string is shown as a plain label.

Custom cell rendering Jump to heading

Use a column's render function to return custom content. It receives the cell's value and the full row, and may return an HTML string, a DOM node, or a Lit template.

<quiet-data-table
  label="Team"
  data-quiet-preload="quiet-avatar quiet-badge quiet-button"
  id="data-table__render"
></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__render');

  grid.columns = [
    {
      field: 'name',
      label: 'Member',
      flex: 2,
      render: (value, row) => {
        const initials = value.split(' ').map(word => word[0]).join('').slice(0, 2);
        const avatar = row.avatar
          ? `<quiet-avatar label="${value}" image="${row.avatar}" style="--size: 1.75rem;"></quiet-avatar>`
          : `<quiet-avatar label="${value}" characters="${initials}" style="--size: 1.75rem;"></quiet-avatar>`;
        return `<div style="display: flex; align-items: center; gap: .5rem; min-width: 0;">
          ${avatar}
          <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${value}</span>
        </div>`;
      }
    },
    {
      field: 'level',
      label: 'Level',
      flex: 1,
      render: value => `<quiet-badge>${value}</quiet-badge>`
    },
    {
      field: 'id',
      label: '',
      align: 'end',
      sortable: false,
      render: () => `<quiet-button size="xs" appearance="text">Edit</quiet-button>`
    }
  ];

  const photo = '?q=80&w=128&h=128&auto=format&fit=crop';

  grid.data = [
    { id: 1, name: 'Whiskers', level: 'Senior', avatar: 'https://images.unsplash.com/photo-1596854407944-bf87f6fdd49e' + photo },
    { id: 2, name: 'Mittens', level: 'Principal' },
    { id: 3, name: 'Shadow', level: 'Staff', avatar: 'https://images.unsplash.com/photo-1583524505974-6facd53f4597' + photo }
  ];
</script>

A string returned from render is inserted as HTML without sanitizing it. Never build that string from untrusted input (user-submitted values, URL parameters, or API data you don't control) or you risk cross-site scripting (XSS). Return a DOM node or a Lit template, or escape the data, whenever any part of the cell comes from an untrusted source.

Custom elements you render in a cell won't register on their own, because they're placed inside the table's shadow DOM where the autoloader can't reach them. If you use the autoloader, list each component in a data-quiet-preload attribute on the table (the examples here do this for brevity) or any ancestor. If you use a bundler, import each component as you would anywhere else.

Styling rendered components Jump to heading

Because rendered content lives inside the table's shadow DOM, your page's styles can't reach a control that a render function generates, and neither can ::part() rules.

To style it, adopt a stylesheet into the table's shadow root once it has rendered. Your rules then sit in the same tree as the rendered content, so they can reach its parts.

grid.updateComplete.then(() => {
  // Create a custom stylesheet
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(`
    [data-action="edit"]::part(button):hover {
      background-color: transparent;
    }
  `);

  // Attach it to the grid's shadow root
  grid.shadowRoot.adoptedStyleSheets = [...grid.shadowRoot.adoptedStyleSheets, sheet];
});

Your stylesheet is adopted into the same shadow root the table uses for its own styles, so an unscoped rule can override the table's internals and break its layout or appearance. Always scope custom CSS to a unique class or data-* attribute on your rendered elements so your rules only touch what you intend.

Formatting values Jump to heading

To show a value as currency, a percentage, or a date, return the formatted string from the column's render function. Use Intl for locale-aware numbers and dates. Sorting and filtering operate on the underlying values, so numbers still sort numerically and dates chronologically.

<quiet-data-table label="Accounts" id="data-table__format"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__format');

  const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
  const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, signDisplay: 'exceptZero' });
  const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });

  grid.columns = [
    { field: 'account', label: 'Account', flex: 2 },
    { field: 'balance', label: 'Balance', align: 'end', flex: 1, render: value => currency.format(value) },
    { field: 'change', label: 'Change', align: 'end', flex: 1, render: value => percent.format(value) },
    { field: 'opened', label: 'Opened', align: 'end', flex: 1, render: value => date.format(new Date(value)) }
  ];

  grid.data = [
    { account: 'Checking', balance: 4280.5, change: 0.012, opened: '2021-03-14' },
    { account: 'Savings', balance: 18650, change: 0.034, opened: '2019-11-02' },
    { account: 'Brokerage', balance: 92140.75, change: -0.018, opened: '2023-07-21' }
  ];
</script>

The same technique handles conditional formatting: branch inside render and return styled markup when a value needs attention, like coloring a negative number or flagging low stock. When you only need to restyle a cell or row without changing its content, use cellStyle or getRowStyle instead.

Sorting Jump to heading

Columns are sortable by default; set sortable: false on a column to opt it out. Clicking a header cycles through ascending, descending, and unsorted. Hold Shift while clicking to add a header as a secondary sort instead of replacing the current one; a numbered badge shows each sorted header's priority, and up to three columns can sort at once. Try it below: sort by department, then shift-click Name to sort names within each department.

Set the initial sort with the sort-field and sort-direction attributes. The full sort lives on the sort property as an array of { field, direction } entries in priority order, with sort-field and sort-direction always mirroring its first (primary) entry. Listen for quiet-sort-change to react when the sort changes; the event carries the whole array in event.detail.sort.

Click a header, then shift-click another.

<quiet-data-table label="Staff" id="data-table__multisort"></quiet-data-table>
<p id="data-table__multisort-output" style="margin-block: 1.5rem 0; color: var(--quiet-text-muted);">Click a header, then shift-click another.</p>

<script>
  const grid = document.getElementById('data-table__multisort');
  const output = document.getElementById('data-table__multisort-output');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'department', label: 'Department', flex: 1 },
    { field: 'salary', label: 'Salary', align: 'end', flex: 1, render: value => `$${value.toLocaleString()}` }
  ];

  grid.data = [
    { name: 'Whiskers', department: 'Engineering', salary: 120000 },
    { name: 'Mittens', department: 'Engineering', salary: 145000 },
    { name: 'Shadow', department: 'Design', salary: 110000 },
    { name: 'Cleo', department: 'Design', salary: 115000 },
    { name: 'Salem', department: 'Engineering', salary: 132000 },
    { name: 'Biscuit', department: 'Research', salary: 138000 }
  ];

  grid.addEventListener('quiet-sort-change', event => {
    output.textContent = event.detail.sort.length
      ? 'Sort: ' + event.detail.sort.map(entry => `${entry.field} ${entry.direction}`).join(', ')
      : 'Click a header, then shift-click another.';
  });
</script>

A few per-column options fine-tune sorting. Set invertSort: true to flip a column's ascending and descending order, which suits rank-like columns where a lower value is "better" and should lead when sorted ascending. By default, empty values (null and undefined) sort to the end regardless of direction; set sortNulls: 'first' to send them to the start instead. For full control over a column's order, provide a sort function. It receives two cell values and returns a number, exactly like the callback you'd pass to Array.prototype.sort.

// Sort priorities by severity rather than alphabetically
const order = { Critical: 0, High: 1, Medium: 2, Low: 3 };

grid.columns = [
  { field: 'title', label: 'Ticket' },
  { field: 'priority', label: 'Priority', sort: (a, b) => order[a] - order[b] }
];

Searching Jump to heading

Add the with-search attribute to show a search field in the action bar. The search filters across all columns and debounces typing, so large data sets filter once per pause instead of once per keystroke. You can also set or read the search query with the query property, which applies immediately. Use search-placeholder to customize the field's placeholder text. To keep a column out of the search, such as an internal ID or a status flag, set searchable: false on it. This affects only the global search, not the column's own filter.

By default the search keeps rows whose value contains the query. Set match="fuzzy" to tolerate typos and transpositions instead, using the same matcher the search list uses. The example below is fuzzy, so try searching for wiskrs or mitens. For full control, set match="custom" and provide an isMatch function that receives the query, a cell's text, and the row.

<quiet-data-table
  label="Cats"
  match="fuzzy"
  search-placeholder="Search cats…"
  with-search
  id="data-table__search"
></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__search');

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'breed', label: 'Breed', flex: 1 },
      { field: 'toy', label: 'Favorite toy', flex: 1 }
    ];

    grid.data = [
      { name: 'Whiskers', breed: 'Tabby', toy: 'Feather wand' },
      { name: 'Mittens', breed: 'Calico', toy: 'Crinkle ball' },
      { name: 'Shadow', breed: 'Bombay', toy: 'Catnip mouse' },
      { name: 'Pumpkin', breed: 'Maine Coon', toy: 'Laser pointer' }
    ];
  }
</script>

Column filters Jump to heading

Set filterable: true on a column to add a filter button to its header. Clicking it opens a menu. The filterType option controls what that menu looks like:

  • set (the default) is a searchable checklist of the column's unique values. It's a faceted filter, so each column's checklist reflects the rows left by the other active filters. Rows with an empty value appear in the checklist as a localized "(Blank)" entry. To keep the menu fast on high-cardinality columns, the checklist shows at most 200 values at a time and asks users to search when more exist.
  • text is a single input that matches values containing what's typed.
  • number is a single numeric input that matches values containing what's typed.
  • range is a pair of minimum and maximum number inputs that keep rows whose value falls within the bounds, inclusively. Either bound may be left blank for an open-ended range, and the inputs show the column's current data range as placeholder text.
  • date-range is a pair of from and to date inputs that keep rows whose value falls within the chosen days, inclusive: filter from June 1 to June 30 and anything on those days passes. The column's values can be Date objects, parseable date strings, or epoch-milliseconds numbers; date-only strings like 2026-06-15 resolve in local time, so they land on the same calendar day the user sees.

Each column below uses a different filter type. Listen for quiet-filter-change to react when the global search or any column filter changes, or call clearFilters() to clear the global search and every column filter at once.

<quiet-data-table label="Orders" id="data-table__filters"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__filters');
    const dateFormat = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' });

    grid.columns = [
      {
        field: 'customer',
        label: 'Customer',
        flex: 1,
        filterable: true,
        filterType: 'text',
        filterPlaceholder: 'Search names…'
      },
      { field: 'status', label: 'Status', flex: 1, filterable: true },
      {
        field: 'total',
        label: 'Total',
        align: 'end',
        flex: 1,
        filterable: true,
        filterType: 'range',
        render: value => `$${value}`
      },
      {
        field: 'placed',
        label: 'Placed',
        align: 'end',
        flex: 1,
        filterable: true,
        filterType: 'date-range',
        render: value => dateFormat.format(new Date(`${value}T00:00:00`))
      }
    ];

    grid.data = [
      { customer: 'Whiskers', status: 'Shipped', total: 42, placed: '2026-05-14' },
      { customer: 'Mittens', status: 'Pending', total: 128, placed: '2026-06-02' },
      { customer: 'Shadow', status: 'Shipped', total: 76, placed: '2026-04-27' },
      { customer: 'Pumpkin', status: 'Cancelled', total: 15, placed: '2026-06-19' },
      { customer: 'Biscuit', status: 'Pending', total: 210, placed: '2026-03-08' },
      { customer: 'Cleo', status: 'Shipped', total: 64, placed: '2026-05-30' }
    ];

    grid.addEventListener('quiet-filter-change', event => {
      console.log(event.detail.query, event.detail.columnFilters);
    });
  }
</script>

Rows without a parseable date fail a date-range filter as soon as either bound is set, so they disappear rather than lingering in a date-bounded view.

To customize the placeholder text in a filter's inputs, set filterPlaceholder on a set, text, or number column, or filterPlaceholderMin and filterPlaceholderMax on a range or date-range column. Range placeholders default to the column's current lowest and highest values (falling back to a localized "Min" and "Max"), and date ranges to a localized "Start" and "End".

A text or number filter keeps rows whose value contains what's typed. Set filterOperator to match differently: startsWith and endsWith anchor the match to the start or end of the value, equals matches the whole value (all case-insensitive, like the default contains), and greaterThan, greaterThanOrEqual, lessThan, and lessThanOrEqual compare numerically when both sides are numbers and fall back to comparing text otherwise. This column treats the number input as a minimum, keeping orders at or above the amount entered:

{
  field: 'total',
  label: 'Total (min)',
  align: 'end',
  filterable: true,
  filterType: 'number',
  filterOperator: 'greaterThanOrEqual'
}

When no operator fits (a date window, fuzzy matching, or matching against another field), give the column a filter function instead. It receives the cell's value, the typed query, and the full row, and returns whether the row should pass. This matcher lets one input search the customer and status fields together:

{
  field: 'customer',
  label: 'Customer',
  filterable: true,
  filterType: 'text',
  filter: (value, query, row) => `${value} ${row.status}`.toLowerCase().includes(query.toLowerCase())
}

Both filterOperator and the callback apply to text and number filters only, and the callback wins when both are set. Neither affects set (faceted) filters or the global search, and both are bypassed in manual mode, where filtering happens on the server.

Pagination Jump to heading

Add the paginate attribute and set page-size to break data into pages. Pagination controls appear in the footer when there's more than one page.

<quiet-data-table label="Adoptions" page-size="8" paginate id="data-table__pagination"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__pagination');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'breed', label: 'Breed', minWidth: 180 },
    { field: 'age', label: 'Age', align: 'end', minWidth: 120 },
    { field: 'weight', label: 'Weight', align: 'end', minWidth: 120 }
  ];

  const names = [
    'Whiskers', 'Shadow', 'Mittens', 'Cleo', 'Pumpkin', 'Biscuit', 'Luna', 'Oliver',
    'Simba', 'Nala', 'Tigger', 'Muffin', 'Pepper', 'Ginger', 'Smokey', 'Mochi',
    'Bandit', 'Coco', 'Waffles', 'Peanut', 'Salem', 'Willow', 'Jasper', 'Poppy',
    'Marbles', 'Noodle', 'Sushi', 'Tofu', 'Pickles', 'Rocket', 'Clover', 'Bagel',
    'Hazel', 'Gizmo', 'Miso', 'Truffle', 'Nutmeg', 'Basil', 'Dusty', 'Olive'
  ];
  const breeds = ['Maine Coon', 'Siamese', 'Ragdoll', 'Bengal', 'Persian', 'Tabby', 'Sphynx', 'British Shorthair'];

  grid.data = names.map((name, i) => {
    const years = 1 + ((i * 3) % 14);
    return {
      name,
      breed: breeds[(i * 5) % breeds.length],
      age: `${years} ${years === 1 ? 'year' : 'years'}`,
      weight: `${(6 + ((i * 7) % 61) / 10).toFixed(1)} lbs`
    };
  });
</script>

Column sizing Jump to heading

By default, every column sizes itself to fit its content: the table measures each column's header and visible cells, then picks a width that fits. The last column stays flexible, stretching to fill any remaining space so the table stays flush with its container.

To take control of individual columns, set width, flex, minWidth, or maxWidth when defining a column; each is described in the column options table. When you combine them, an explicit width (or a pinned edge) opts a column out of flexing, minWidth is the floor a column won't shrink below, and maxWidth is the ceiling automatic sizing won't grow past (an explicit width is honored as-is). When several columns flex, the leftover space is shared by ratio, so a flex: 2 column grows twice as fast as a flex: 1 one, and the automatic last-column stretch steps aside. When space runs short, a flex column settles at its floor and the table scrolls.

Add the resizable attribute and users can drag any column's trailing edge to resize it. To control resizing per column, set resizable: true or resizable: false on individual columns. The same bounds apply while dragging: a column never drags below its minWidth, and if you set a maxWidth it won't drag beyond that either (without one, a column can be dragged as wide as you like). A column drops its flex once you resize it by hand, and each resize emits quiet-column-resize.

To snap a column back to fit its content, double-click its resize handle, or hold or Shift while double-clicking to fit every column at once. The same actions are available programmatically as autoSizeColumn(field) and autoSizeAllColumns(), and keyboard users can reach the per-column one from the column menu's Autosize to fit item.

The example below mixes all of them: some columns size themselves to fit, Kind starts at a fixed width, a few set a minWidth or maxWidth to bound how far they can go, and Modified flexes to absorb the leftover space. Drag the column edges to see where each one stops.

<quiet-data-table label="Files" resizable id="data-table__sizing"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__sizing');

  grid.columns = [
    { field: 'name', label: 'Name' },
    { field: 'kind', label: 'Kind', width: 120, maxWidth: 200 },
    { field: 'size', label: 'Size', align: 'end', minWidth: 100, maxWidth: 220 },
    { field: 'owner', label: 'Owner', maxWidth: 200 },
    { field: 'modified', label: 'Modified', align: 'end', minWidth: 120, flex: 1 }
  ];

  grid.data = [
    { name: 'Quarterly financial projections (draft).xlsx', kind: 'Spreadsheet', size: '2.4 MB', owner: 'Whiskers', modified: '2 hours ago' },
    { name: 'logo.svg', kind: 'Image', size: '14 KB', owner: 'Shadow', modified: 'Yesterday' },
    { name: 'Annual report and supporting appendices.pdf', kind: 'Document', size: '8.1 MB', owner: 'Mittens', modified: '3 days ago' },
    { name: 'notes.txt', kind: 'Text', size: '1 KB', owner: 'Cleo', modified: 'Last week' },
    { name: 'product-launch-keynote-final-v3.key', kind: 'Presentation', size: '52 MB', owner: 'Pumpkin', modified: '5 hours ago' },
    { name: 'budget.csv', kind: 'Spreadsheet', size: '320 KB', owner: 'Biscuit', modified: 'Today' }
  ];
</script>

Truncating long values Jump to heading

By default, a value too wide for its column clips with a trailing ellipsis, hiding the end. When both ends of a value carry meaning (file paths, URLs, IDs, hashes), set truncate to 'start' or 'center' so the column keeps the important parts visible and moves the ellipsis instead. The full value is available on hover and to assistive technology.

This only controls where the ellipsis falls, not the column's width: a truncating column still auto-sizes to fit its content and respects maxWidth like any other, clipping only once the value exceeds that width. The positional values apply to plain-text columns and are skipped when a column has a render function. Resize the columns to watch the ellipsis follow the available space.

To stop a column from clipping at all, set truncate to false. This drops the trailing ellipsis so plain text wraps to fit its content, growing the row's height instead of hiding anything. It's also useful for render columns whose content isn't text (like a sparkline or badge), where the default ellipsis would otherwise appear beside the rendered element. A render function that clips its own markup (with overflow and white-space styles) still controls how that content behaves.

<quiet-data-table label="Deployments" resizable id="data-table__truncate"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__truncate');

  grid.columns = [
    { field: 'path', label: 'File', truncate: 'center', maxWidth: 280 },
    { field: 'url', label: 'Preview URL', truncate: 'start', maxWidth: 240 },
    { field: 'commit', label: 'Commit', width: 100 },
    { field: 'when', label: 'Deployed', align: 'end' }
  ];

  grid.data = [
    { path: 'src/components/data-table/data-table.styles.ts', commit: '9f3c1a2e7b04', url: 'https://app.example.com/previews/pr-1842/index.html', when: '2 hours ago' },
    { path: 'docs/docs/components/truncate.md', commit: 'c70d5b9aa118', url: 'https://app.example.com/previews/pr-1839/docs/truncate', when: 'Yesterday' },
    { path: 'src/utilities/table-controller.ts', commit: '41e8f6d0c2aa', url: 'https://app.example.com/previews/pr-1835/index.html', when: '3 days ago' }
  ];
</script>

Reordering columns Jump to heading

Add the reorderable attribute and users can drag a column's header to move it to a new position. A ghost of the header follows the pointer and the other columns slide aside to show where it will land. Grabbing a pinned column's header unpins it so it can be dragged, and dragging another column across a pinned one unpins that column as well. Columns can also be moved and pinned from the column menu.

Each move emits quiet-column-reorder with the moved column and the new columnOrder, and you can reorder programmatically with moveColumn(field, toIndex).

<quiet-data-table label="Files" reorderable id="data-table__reorder"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__reorder');

  grid.columns = [
    { field: 'name', label: 'Name', width: 200 },
    { field: 'type', label: 'Type', flex: 1 },
    { field: 'size', label: 'Size', align: 'end', flex: 1 },
    { field: 'owner', label: 'Owner', flex: 1 }
  ];

  grid.data = [
    { name: 'annual-report.pdf', type: 'PDF', size: '2.4 MB', owner: 'Whiskers' },
    { name: 'budget.xlsx', type: 'Spreadsheet', size: '512 KB', owner: 'Shadow' },
    { name: 'logo.svg', type: 'Image', size: '18 KB', owner: 'Mittens' }
  ];

  grid.addEventListener('quiet-column-reorder', event => {
    console.log('Moved', event.detail.column.field, 'New order:', event.detail.columnOrder);
  });
</script>

Toggling column visibility Jump to heading

Add with-column-toggle to let users show and hide columns from a dropdown in the action bar. Set hideable: false on a column to keep it always visible, or hidden: true to hide it initially. Call showAllColumns() to reveal every hidden column at once.

<quiet-data-table label="Contacts" with-column-toggle id="data-table__visibility"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__visibility');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1, hideable: false },
    { field: 'email', label: 'Email', flex: 2 },
    { field: 'phone', label: 'Phone', flex: 1 },
    { field: 'company', label: 'Company', flex: 1, hidden: true }
  ];

  grid.data = [
    { name: 'Whiskers', email: 'whiskers@example.com', phone: '555-0100', company: 'Analytical' },
    { name: 'Mittens', email: 'mittens@example.com', phone: '555-0101', company: 'Bletchley' }
  ];
</script>

Column menu Jump to heading

Add with-column-menu to give each column a menu button in its header with quick actions: sort ascending or descending, autosize to fit (when resizing is enabled), pin to the start or end, move to the start or end (when reorderable is set), and hide the column. Pinning and hiding from the menu can be undone: unpin from the same menu, and bring a hidden column back with the column toggle (with-column-toggle).

The menu is a real <quiet-dropdown>, exposed through the column-menu and column-menu-button parts, so you can theme it with the dropdown's own parts and custom properties.

<quiet-data-table
  label="Files"
  with-column-menu
  with-column-toggle
  resizable
  reorderable
  id="data-table__menu"
></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__menu');

  grid.columns = [
    { field: 'name', label: 'Name', width: 200 },
    { field: 'type', label: 'Type', flex: 1 },
    { field: 'size', label: 'Size', align: 'end', flex: 1 },
    { field: 'owner', label: 'Owner', flex: 1 }
  ];

  grid.data = [
    { name: 'annual-report.pdf', type: 'PDF', size: '2.4 MB', owner: 'Whiskers' },
    { name: 'budget.xlsx', type: 'Spreadsheet', size: '512 KB', owner: 'Shadow' },
    { name: 'logo.svg', type: 'Image', size: '18 KB', owner: 'Mittens' },
    { name: 'notes.md', type: 'Markdown', size: '6 KB', owner: 'Cleo' }
  ];
</script>

Pinning columns Jump to heading

Pin columns to either side with pinned: 'start' or pinned: 'end' so they stay visible while scrolling horizontally. Users can also pin and unpin columns from the column menu. This spreadsheet has more columns than fit, so the pinned Name and Total columns stay put as you scroll sideways.

<quiet-data-table label="Spreadsheet" resizable id="data-table__pinning"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__pinning');

  grid.columns = [
    { field: 'name', label: 'Name', pinned: 'start', width: 160 },
    { field: 'q1', label: 'Q1', align: 'end', width: 120 },
    { field: 'q2', label: 'Q2', align: 'end', width: 120 },
    { field: 'q3', label: 'Q3', align: 'end', width: 120 },
    { field: 'q4', label: 'Q4', align: 'end', width: 120 },
    { field: 'total', label: 'Total', align: 'end', pinned: 'end', width: 140 }
  ];

  grid.data = [
    { name: 'Widgets', q1: 1200, q2: 1500, q3: 1800, q4: 2100, total: 6600 },
    { name: 'Gadgets', q1: 900, q2: 1100, q3: 1300, q4: 1700, total: 5000 },
    { name: 'Gizmos', q1: 400, q2: 600, q3: 800, q4: 1200, total: 3000 }
  ];
</script>

Give pinned columns an explicit width so the table can position them correctly.

Grouped column headers Jump to heading

Nest columns under a group to produce banded, multi-level headers. Instead of a column, pass a group object ({ label, columns }) and the group spans its child columns with a header above them. Groups can nest for deeper bands.

Sorting, filtering, resizing, and the column menu all operate on the leaf columns as usual; the group header is just a label.

<quiet-data-table label="Cats" style="--max-height: 320px;" id="data-table__groups"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__groups');

    grid.columns = [
      { field: 'name', label: 'Cat', pinned: 'start', width: 140 },
      {
        label: 'Vitals',
        columns: [
          { field: 'weight', label: 'Weight (kg)', align: 'end', flex: 1 },
          { field: 'age', label: 'Age', align: 'end', flex: 1 }
        ]
      },
      {
        label: 'Care',
        columns: [
          { field: 'vet', label: 'Last vet visit', flex: 1 },
          { field: 'grooming', label: 'Grooming', flex: 1 }
        ]
      }
    ];

    grid.data = [
      { name: 'Whiskers', weight: 4.2, age: 3, vet: 'Mar 2026', grooming: 'Monthly' },
      { name: 'Mittens', weight: 3.8, age: 5, vet: 'Jan 2026', grooming: 'Weekly' },
      { name: 'Shadow', weight: 5.1, age: 2, vet: 'Apr 2026', grooming: 'Rarely' },
      { name: 'Pumpkin', weight: 6.7, age: 7, vet: 'Feb 2026', grooming: 'Monthly' },
      { name: 'Biscuit', weight: 4.9, age: 4, vet: 'May 2026', grooming: 'Weekly' },
      { name: 'Luna', weight: 3.5, age: 1, vet: 'Apr 2026', grooming: 'Monthly' }
    ];
  }
</script>

Two limitations apply while a group is present: columns inside a group can't be pinned (pin a standalone column instead, like the "Cat" column here), and column reordering is turned off.

A group object takes a label, an optional align for the header, and a columns array of leaf columns or nested groups.

Row selection Jump to heading

Set selection to single or multiple to add a selection column. In single mode users pick one row at a time, with each pick replacing the last; in multiple mode every row gets a checkbox, and holding Shift while clicking one selects every row between it and the last one clicked. Read the current selection from the selectedRows property (or selectedKeys), listen for the quiet-row-selection-change event, and call selectAll() and clearSelection() to change it programmatically.

When the status bar is shown, it summarizes the current selection and offers quick "Select all N" and "Clear" actions. Select all the rows on screen below to reveal "Select all" for the rows hidden by pagination or a filter.

Nothing selected

<quiet-data-table label="Invitees" selection="multiple" id="data-table__selection"></quiet-data-table>
<p id="data-table__selection-output" style="margin-block: 1.5rem 0; color: var(--quiet-text-muted);">Nothing selected</p>

<script>
  const grid = document.getElementById('data-table__selection');
  const output = document.getElementById('data-table__selection-output');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'email', label: 'Email', flex: 2 }
  ];

  grid.data = [
    { id: 1, name: 'Whiskers', email: 'whiskers@example.com' },
    { id: 2, name: 'Mittens', email: 'mittens@example.com' },
    { id: 3, name: 'Shadow', email: 'shadow@example.com' }
  ];

  grid.addEventListener('quiet-row-selection-change', event => {
    const names = event.detail.selectedRows.map(row => row.name);
    output.textContent = names.length ? `Selected: ${names.join(', ')}` : 'Nothing selected';
  });
</script>

For selection to stay correct as rows are sorted, filtered, and paged, the table needs a stable identity for each row. It uses each row's id or key property automatically, or you can provide a getRowId function. Without one of these, rows fall back to their index and selection becomes positional, so include an id whenever you use selection (and always in manual mode).

When some rows shouldn't be selected, set the isRowSelectable property to a function that receives a row and returns whether it can be. Return false and the row's checkbox renders disabled, and the header checkbox, the status bar's "Select all N" action, selectAll(), and Shift ranges all skip it. Ranges sweep right over unselectable rows without touching them. This gates the selection UI only: keys you assign to selectedKeys programmatically are applied as-is.

// Cats who already found a home can't be selected for adoption events
grid.isRowSelectable = row => !row.adopted;

Bulk actions Jump to heading

Combine selection with the actions slot to act on several rows at once. Listen for quiet-row-selection-change to reveal an action bar when a selection exists, then read selectedRows (or selectedKeys) when an action runs. Select some rows below to reveal the bar.

<quiet-data-table label="Members" selection="multiple" with-search id="data-table__bulk">
  <div slot="actions" id="data-table__bulk-bar" style="display: none;">
    <quiet-button variant="destructive" data-action="remove">
      <quiet-icon slot="start" name="trash"></quiet-icon>
      Remove
    </quiet-button>
  </div>
</quiet-data-table>

<script>
  const grid = document.getElementById('data-table__bulk');
  const bar = document.getElementById('data-table__bulk-bar');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'email', label: 'Email', flex: 2 },
    { field: 'role', label: 'Role', flex: 1 }
  ];

  grid.data = [
    { id: 1, name: 'Whiskers', email: 'whiskers@example.com', role: 'Owner' },
    { id: 2, name: 'Mittens', email: 'mittens@example.com', role: 'Admin' },
    { id: 3, name: 'Shadow', email: 'shadow@example.com', role: 'Editor' },
    { id: 4, name: 'Cleo', email: 'cleo@example.com', role: 'Viewer' },
    { id: 5, name: 'Pumpkin', email: 'pumpkin@example.com', role: 'Editor' }
  ];

  grid.addEventListener('quiet-row-selection-change', event => {
    const selected = event.detail.selectedRows.length;
    bar.style.display = selected ? 'flex' : 'none';
  });

  bar.querySelector('[data-action="remove"]').addEventListener('click', () => {
    const keys = new Set(grid.selectedKeys);
    grid.data = grid.data.filter(row => !keys.has(String(row.id)));
    grid.clearSelection();
  });
</script>

Row actions Jump to heading

Render buttons in a column to act on individual rows. Because cell content lives in the table's shadow DOM, listen for clicks on the table and use composedPath() to find which button was pressed. Give the column a fixed width and set sortable: false to keep it tidy.

Use the buttons to act on a row.

<quiet-data-table label="Members" data-quiet-preload="quiet-button" id="data-table__actions"></quiet-data-table>
<p id="data-table__actions-output" style="margin-block: 1.5rem 0; color: var(--quiet-text-muted);">Use the buttons to act on a row.</p>

<script>
  const grid = document.getElementById('data-table__actions');
  const output = document.getElementById('data-table__actions-output');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'email', label: 'Email', flex: 2 },
    {
      field: 'id',
      label: '',
      align: 'end',
      sortable: false,
      width: 180,
      render: (value, row) =>
        `<quiet-button size="xs" appearance="text" data-action="edit" data-id="${row.id}">Edit</quiet-button>
         <quiet-button size="xs" appearance="text" data-action="remove" data-id="${row.id}">Remove</quiet-button>`
    }
  ];

  grid.data = [
    { id: 1, name: 'Whiskers', email: 'whiskers@example.com' },
    { id: 2, name: 'Mittens', email: 'mittens@example.com' },
    { id: 3, name: 'Shadow', email: 'shadow@example.com' }
  ];

  grid.addEventListener('click', event => {
    const button = event.composedPath().find(el => el.dataset && el.dataset.action);
    if (!button) return;
    const row = grid.data.find(item => String(item.id) === button.dataset.id);
    output.textContent = `${button.dataset.action === 'edit' ? 'Editing' : 'Removing'} ${row.name}`;
  });
</script>

When the whole row is the action, such as opening a record, listen for quiet-row-click instead. It fires when a user clicks anywhere in a row, and its detail includes the row's row data, its key, and its index within the current view.

Inline editing Jump to heading

A column's render function can return a form control to edit a cell in place. Here, only the role is editable, so the rest of the table stays easy to scan. Listen for quiet-change on the table and write the control's value back to the matching row, finding the control with composedPath() as shown in row actions.





          
<quiet-data-table label="Members" data-quiet-preload="quiet-select" id="data-table__inline-edit"></quiet-data-table>
<pre id="data-table__inline-edit-output" style="margin-block: 1.5rem 0; color: var(--quiet-text-muted);"></pre>

<script>
  {
    const grid = document.getElementById('data-table__inline-edit');
    const output = document.getElementById('data-table__inline-edit-output');

    grid.data = [
      { id: 1, name: 'Whiskers', email: 'whiskers@example.com', role: 'Owner', lastActive: 'Today' },
      { id: 2, name: 'Mittens', email: 'mittens@example.com', role: 'Editor', lastActive: '2 days ago' },
      { id: 3, name: 'Shadow', email: 'shadow@example.com', role: 'Viewer', lastActive: 'Last week' }
    ];

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'email', label: 'Email', flex: 2 },
      {
        field: 'role',
        label: 'Role',
        sortable: false,
        width: 160,
        render: (value, row) => `
          <quiet-select size="sm" value="${value}" data-id="${row.id}">
            <option value="Owner">Owner</option>
            <option value="Editor">Editor</option>
            <option value="Viewer">Viewer</option>
          </quiet-select>
        `
      },
      { field: 'lastActive', label: 'Last active', flex: 1 }
    ];

    const showData = () => {
      output.textContent = grid.data.map(row => `${row.name}${row.role}`).join('\n');
    };

    // Update the row in place to avoid a re-render that would interrupt the edit
    grid.addEventListener('quiet-change', event => {
      const control = event.composedPath().find(el => el.dataset && el.dataset.id);
      if (!control) return;
      const row = grid.data.find(item => String(item.id) === control.dataset.id);
      row.role = control.value;
      showData();
    });

    showData();
  }
</script>

Update the row object in place rather than reassigning data. Reassigning re-renders the table mid-edit, which steals focus from the control the user is typing in.

Editing in a dialog Jump to heading

When an edit touches several fields at once, put the form in a dialog instead of in the cell. Render an Edit button in each row, copy the clicked row's values into the form, then assign a new data array on submit so the table re-renders with the changes.

Edit member

Cancel Save
<quiet-data-table label="Members" data-quiet-preload="quiet-button" id="data-table__dialog-edit"></quiet-data-table>

<quiet-dialog id="data-table__dialog-edit-dialog">
  <h3 slot="header" style="font-size: 1.25rem; margin-block: 0;">Edit member</h3>
  <form id="data-table__dialog-edit-form">
    <quiet-text-field name="name" label="Name" required></quiet-text-field>
    <quiet-select name="role" label="Role" style="margin-block-start: 1rem;">
      <option value="Owner">Owner</option>
      <option value="Editor">Editor</option>
      <option value="Viewer">Viewer</option>
    </quiet-select>
  </form>
  <quiet-button slot="footer" data-dialog="close">Cancel</quiet-button>
  <quiet-button slot="footer" variant="primary" type="submit" form="data-table__dialog-edit-form">Save</quiet-button>
</quiet-dialog>

<script>
  {
    const grid = document.getElementById('data-table__dialog-edit');
    const dialog = document.getElementById('data-table__dialog-edit-dialog');
    const form = document.getElementById('data-table__dialog-edit-form');
    const nameField = form.querySelector('[name="name"]');
    const roleField = form.querySelector('[name="role"]');
    let editingId = null;

    grid.data = [
      { id: 1, name: 'Whiskers', role: 'Owner' },
      { id: 2, name: 'Mittens', role: 'Editor' },
      { id: 3, name: 'Shadow', role: 'Viewer' }
    ];

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'role', label: 'Role', flex: 1 },
      {
        field: 'id',
        label: '',
        align: 'end',
        sortable: false,
        render: (value, row) =>
          `<quiet-button size="sm" appearance="text" data-action="edit" data-id="${row.id}">Edit</quiet-button>`
      }
    ];

    // Open the dialog with the row's current values
    grid.addEventListener('click', event => {
      const button = event.composedPath().find(el => el.dataset && el.dataset.action === 'edit');
      if (!button) return;
      const row = grid.data.find(item => String(item.id) === button.dataset.id);
      editingId = row.id;
      nameField.value = row.name;
      roleField.value = row.role;
      dialog.open = true;
    });

    // Write the edited values back and re-render by assigning a new data array
    form.addEventListener('submit', event => {
      event.preventDefault();
      grid.data = grid.data.map(row =>
        row.id === editingId ? { ...row, name: nameField.value, role: roleField.value } : row
      );
      dialog.open = false;
    });
  }
</script>

Expandable rows Jump to heading

Set the renderRowDetail property to a function that receives a row and returns that row's panel content as an HTML string, a DOM node, or a Lit template, the same shapes a column's render accepts.

The toggle appears on every row and there's no way to show it on only a subset, so a row whose function returns nothing still opens to an empty panel. If only certain rows have details, branch inside the function and return a consistent placeholder for the rest.

Listen for quiet-expand-change to react when rows open or close; its detail includes the expandedKeys and the expandedRows. Add the single-expand attribute to keep only one panel open at a time. Open and close panels programmatically with expandRow(key), collapseRow(key), toggleRow(key), expandAll(), and collapseAll(). You can also assign an array of row keys to the expandedKeys property to set the expansion directly, including an initial state before the user opens anything.

<quiet-data-table label="Cats" single-expand id="data-table__expand"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__expand');

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'breed', label: 'Breed', flex: 1 },
      { field: 'naps', label: 'Naps/day', align: 'end', flex: 1 }
    ];

    grid.data = [
      { id: 1, name: 'Whiskers', breed: 'Tabby', naps: 7, bio: 'Knocks pens off desks with surgical precision.' },
      { id: 2, name: 'Mittens', breed: 'Calico', naps: 9, bio: 'Professional sunbeam locator and warm-laundry inspector.' },
      { id: 3, name: 'Shadow', breed: 'Bombay', naps: 6, bio: 'Materializes at the sound of a treat bag from three rooms away.' }
    ];

    grid.renderRowDetail = row => `
      <strong>${row.name}</strong> the ${row.breed.toLowerCase()}${row.naps} naps a day.
      <p style="margin-block: 0.5rem 0; color: var(--quiet-text-muted);">${row.bio}</p>
    `;
  }
</script>

When renderRowDetail returns a string, the markup is inserted as-is and is not sanitized. Never build that string from untrusted input (user-submitted values, URL parameters, API data you don't control), or you risk cross-site scripting (XSS). Return a DOM node or a Lit template, or escape the data, whenever any part of the panel comes from an untrusted source.

Pinning rows Jump to heading

Pin rows to the top or bottom with the pinnedRows property, giving the keys of the rows to pin. Pinned rows stay visible while the center body scrolls and persist across pages, which makes them useful for a summary or other always-visible row. Keys use the same identity as row selection: the row's id or key property, or your getRowId function. Keys that don't match a row are ignored.

Row pinning is configured by you, not toggled by the user. Pinned rows are presentational, so they don't show selection or expansion controls. Here, a "Team average" summary row is pinned to the bottom.

<quiet-data-table label="Cats" style="--max-height: 300px;" id="data-table__pin-rows"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__pin-rows');

    grid.columns = [
      { field: 'name', label: 'Cat', flex: 1 },
      { field: 'naps', label: 'Naps/day', align: 'end', flex: 1 },
      { field: 'treats', label: 'Treats/day', align: 'end', flex: 1 }
    ];

    const cats = [
      { id: 1, name: 'Whiskers', naps: 7, treats: 4 },
      { id: 2, name: 'Mittens', naps: 9, treats: 6 },
      { id: 3, name: 'Shadow', naps: 6, treats: 3 },
      { id: 4, name: 'Pumpkin', naps: 8, treats: 5 },
      { id: 5, name: 'Tigerlily', naps: 5, treats: 7 },
      { id: 6, name: 'Biscuit', naps: 10, treats: 2 },
      { id: 7, name: 'Cleo', naps: 8, treats: 4 },
      { id: 8, name: 'Oliver', naps: 6, treats: 5 }
    ];

    // A computed summary row, pinned to the bottom by its key
    const avg = key => Math.round(cats.reduce((sum, cat) => sum + cat[key], 0) / cats.length);
    grid.data = [...cats, { id: 'summary', name: 'Team average', naps: avg('naps'), treats: avg('treats') }];
    grid.pinnedRows = { bottom: ['summary'] };
  }
</script>

Pinned rows sit outside arrow-key cell navigation and the row counts screen readers announce, so keep anything essential in the data itself.

Grouping rows Jump to heading

Group rows by a column with the groupBy property, or set the group-by attribute to a space- or comma-separated list of fields. Each group is led by a collapsible header row showing the group's value and row count, plus a summary for any column that defines an aggregate (the same option that drives column footers). While grouped, the grouped column is hidden from the table because its values live on the group rows.

Groups start expanded. Click anywhere on a group row to toggle it, or call the expandAllGroups() and collapseAllGroups() methods. Sorting and filtering keep working: filters apply to the underlying rows before grouping, and sorting orders rows within their groups (sorting by the grouped column reorders the groups themselves). Selection skips group rows entirely, so the header's select-all checkbox and shift-selected ranges only ever touch data rows.

Expand all Collapse all
<quiet-data-table label="Shelter residents" group-by="location" style="--max-height: 400px;" id="data-table__group-by">
  <quiet-button slot="actions" id="data-table__group-by-expand">
    <quiet-icon slot="start" name="chevrons-down"></quiet-icon>
    Expand all
  </quiet-button>
  <quiet-button slot="actions" id="data-table__group-by-collapse">
    <quiet-icon slot="start" name="chevrons-up"></quiet-icon>
    Collapse all
  </quiet-button>
</quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__group-by');

    grid.columns = [
      { field: 'name', label: 'Cat', flex: 1 },
      { field: 'location', label: 'Location', flex: 1 },
      { field: 'naps', label: 'Naps/day', align: 'end', flex: 1, aggregate: 'sum' },
      { field: 'treats', label: 'Treats/day', align: 'end', flex: 1, aggregate: 'sum' }
    ];

    grid.data = [
      { id: 1, name: 'Whiskers', location: 'Sunroom', naps: 7, treats: 4 },
      { id: 2, name: 'Mittens', location: 'Windowsill', naps: 9, treats: 6 },
      { id: 3, name: 'Shadow', location: 'Reading nook', naps: 6, treats: 3 },
      { id: 4, name: 'Pumpkin', location: 'Sunroom', naps: 8, treats: 5 },
      { id: 5, name: 'Tigerlily', location: 'Windowsill', naps: 5, treats: 7 },
      { id: 6, name: 'Biscuit', location: 'Reading nook', naps: 10, treats: 2 }
    ];

    document.getElementById('data-table__group-by-expand').addEventListener('click', () => grid.expandAllGroups());
    document.getElementById('data-table__group-by-collapse').addEventListener('click', () => grid.collapseAllGroups());
  }
</script>

While rows are grouped, pagination is disabled and pinned rows are ignored (each logs a one-time console warning). groupBy has no effect in manual mode, where grouping is the server's job. Rows with an empty value for the grouped field are gathered under a localized "(Blank)" group.

Pass multiple fields, like grid.groupBy = ['shift', 'station'] or group-by="shift station", to nest groups. Each level indents one step, with rows indented beneath their group's label, and every group row still shows its own count and aggregates.

While grouped, the table exposes itself to assistive technology as a tree grid. To style group rows from your CSS, use the group-row, group-cell, and group-toggle parts, and target the table as a whole with the grouped custom state.

Large data sets Jump to heading

The table renders every row by default. When you constrain the body's height, by setting the --max-height custom property or giving the table a fixed height, and there are at least 50 rows to show, the body scrolls and rows virtualize automatically, keeping only the visible ones (plus a small buffer) in the DOM. This keeps scrolling and resizing fast even with tens of thousands of rows.

<quiet-data-table label="Numbers" id="data-table__virtual"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__virtual');

  grid.columns = [
    { field: 'n', label: 'Number', align: 'end', flex: 1 },
    { field: 'square', label: 'Square', align: 'end', flex: 1 },
    { field: 'cube', label: 'Cube', align: 'end', flex: 1 }
  ];

  grid.data = Array.from({ length: 5000 }, (unused, i) => {
    const n = i + 1;
    return { n, square: n * n, cube: n * n * n };
  });
</script>

<style>
  #data-table__virtual {
    --max-height: 360px;
    --row-height: 3.25rem;
  }
</style>

Smaller result sets always render in full, so nothing changes until at least 50 rows pass the active filters. For the smoothest scrolling, also set a fixed --row-height.

Fetching data Jump to heading

When your data fits in memory, fetch it once and assign it to data. The table sorts, filters, and paginates locally from there. Turn on loading while the request is in flight and clear it when the rows arrive; the attribute overlays a spinner on the body, and you can provide your own overlay content with the loading slot. For datasets too large to load at once, reach for server-side data instead.

<quiet-data-table label="Users" page-size="5" with-search paginate id="data-table__fetch"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__fetch');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'email', label: 'Email', flex: 2 },
    { field: 'company', label: 'Company', flex: 1 }
  ];

  grid.loading = true;

  const people = [
    ['Whiskers', 'Analytical Engines'],
    ['Mittens', 'Bletchley Park'],
    ['Shadow', 'Remington Rand'],
    ['Cleo', 'NASA'],
    ['Pumpkin', 'MIT'],
    ['Biscuit', 'MIT'],
    ['Salem', 'Eindhoven'],
    ['Pepper', 'Sun Microsystems'],
    ['Olive', 'Stanford'],
    ['Smokey', 'CERN'],
    ['Ginger', 'Google'],
    ['Mochi', 'Linux Foundation']
  ];

  // Replace this with a real request, e.g. fetch('/api/users').then(response => response.json())
  const request = new Promise(resolve => {
    setTimeout(
      () =>
        resolve(
          people.map(([name, company], i) => ({
            id: i + 1,
            name,
            email: name.toLowerCase().replace(/[^a-z]+/g, '.') + '@example.com',
            company
          }))
        ),
      1200
    );
  });

  request.then(rows => {
    grid.data = rows;
    grid.loading = false;
  });
</script>

Empty states Jump to heading

When there are no rows to show, the table renders an empty state in place of the body. There are two cases: with no data at all, it shows a "no data" message, and when a search or column filter has excluded every row, it shows a "no matches" message instead. The search below starts with an unmatched term to show this state; clear it to see the data.

<quiet-data-table label="Cities" query="foo" with-search id="data-table__empty"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__empty');

  grid.columns = [
    { field: 'city', label: 'City', flex: 1 },
    { field: 'country', label: 'Country', flex: 1 }
  ];

  grid.data = [
    { city: 'Tokyo', country: 'Japan' },
    { city: 'Paris', country: 'France' },
    { city: 'Cairo', country: 'Egypt' }
  ];
</script>

Replace the built-in message with your own content using the empty slot. Slotted content appears in both cases, whether the table has no data or filters have excluded every row.

You're all caught up.

<quiet-data-table label="Tasks" id="data-table__empty-slot">
  <div slot="empty" style="padding: 2rem; text-align: center;">
    <quiet-icon name="checklist" style="font-size: 2rem; color: var(--quiet-text-muted);"></quiet-icon>
    <p>You're all caught up.</p>
  </div>
</quiet-data-table>

<script>
  const grid = document.getElementById('data-table__empty-slot');
  grid.columns = [
    { field: 'task', label: 'Task', flex: 2 },
    { field: 'due', label: 'Due', flex: 1 }
  ];
  grid.data = [];
</script>

Server-side data Jump to heading

For large or backend-driven datasets, add the manual attribute. The table stops sorting, filtering, and paginating on its own and instead emits quiet-sort-change, quiet-filter-change, and quiet-page-change events so you can fetch the right rows. Set total-rows so pagination knows how many pages to show, and assign each page's rows to data.

<quiet-data-table
  label="Logs"
  page-size="5"
  total-rows="50"
  manual
  with-search
  paginate
  id="data-table__manual"
></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__manual');

  grid.columns = [
    { field: 'time', label: 'Time', width: 180 },
    { field: 'event', label: 'Event', flex: 2 },
    { field: 'source', label: 'Source', flex: 1 },
    { field: 'level', label: 'Level', width: 100 }
  ];

  // Pretend this lives on a server
  const events = [
    ['User signed in', 'auth-service', 'info'],
    ['Profile updated', 'api-gateway', 'info'],
    ['Payment processed', 'billing', 'info'],
    ['Report exported', 'reporting', 'info'],
    ['Cache rebuilt', 'cache', 'info'],
    ['Rate limit reached', 'api-gateway', 'warning'],
    ['Slow query detected', 'database', 'warning'],
    ['Disk usage at 85%', 'storage', 'warning'],
    ['Retrying webhook', 'webhooks', 'warning'],
    ['Login failed', 'auth-service', 'error'],
    ['Payment declined', 'billing', 'error'],
    ['Database timeout', 'database', 'error']
  ];

  const allRows = Array.from({ length: 50 }, (unused, i) => {
    const [event, source, level] = events[i % events.length];
    const date = new Date(Date.UTC(2026, 5, 26, 16, 30) - i * 11 * 60_000);
    return {
      id: 4000 + (50 - i),
      time: date.toISOString().slice(0, 16).replace('T', ' '),
      event,
      source,
      level
    };
  });

  async function fetchPage() {
    const search = (grid.query ?? '').toLowerCase();
    let rows = allRows.filter(row => row.event.toLowerCase().includes(search));
    grid.totalRows = rows.length;

    if (grid.sortField) {
      const dir = grid.sortDirection === 'desc' ? -1 : 1;
      rows = [...rows].sort((a, b) => (a[grid.sortField] > b[grid.sortField] ? 1 : -1) * dir);
    }

    // Show the loader and wait a second to simulate a server response
    grid.loading = true;
    await new Promise(resolve => setTimeout(resolve, 1000));

    // Fall back to the defaults until the element upgrades and its properties initialize
    const page = grid.page ?? 1;
    const pageSize = grid.pageSize ?? 5;
    const start = (page - 1) * pageSize;
    grid.data = rows.slice(start, start + pageSize);
    grid.loading = false;
  }

  grid.addEventListener('quiet-sort-change', fetchPage);
  grid.addEventListener('quiet-filter-change', fetchPage);
  grid.addEventListener('quiet-page-change', fetchPage);

  // Load the first page once the element is defined so the initial data shows on load
  customElements.whenDefined('quiet-data-table').then(fetchPage);
</script>

Remembering the view Jump to heading

Add a state-key attribute to remember how each user left the table. The table persists its view state to localStorage under that key and restores it when it connects. The view state covers the sort, the search query, column filters, column visibility, order, widths, and pinning, plus the current page and page size. Writes are debounced, so a resize drag or fast typing coalesces into a single save. Use a unique key per table.

Sort a column or resize one below, then reload the page to see the view restored.

<quiet-data-table label="Cats" state-key="docs-demo" resizable with-search id="data-table__state"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__state');

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'breed', label: 'Breed', flex: 1 },
      { field: 'age', label: 'Age', align: 'end', flex: 1 },
      { field: 'hobby', label: 'Hobby', flex: 2 }
    ];

    grid.data = [
      { id: 1, name: 'Whiskers', breed: 'Tabby', age: 3, hobby: 'Knocking pens off desks' },
      { id: 2, name: 'Mittens', breed: 'Calico', age: 7, hobby: 'Inspecting warm laundry' },
      { id: 3, name: 'Shadow', breed: 'Bombay', age: 1, hobby: 'Materializing at treat time' },
      { id: 4, name: 'Pumpkin', breed: 'Maine Coon', age: 5, hobby: 'Supervising bird feeders' }
    ];
  }
</script>

Selection and expansion are deliberately not persisted. They're coupled to the loaded data, so restoring them against different rows would select the wrong things.

To store the state somewhere other than localStorage, such as a user's server-side preferences, listen for quiet-state-change. It fires debounced whenever the view changes and a state-key is set, and event.detail.state carries a plain JSON snapshot, the same one getViewState() returns. Reapply a snapshot later with restoreViewState(state). Missing slices are left as they are, restored column widths count as user-sized so auto-sizing won't overwrite them, and restoring emits no events, just like assigning the properties directly.

A persisted state can outlive the columns it was saved for. Stale entries degrade gracefully, since sorts, filters, and ordering for fields that no longer exist are ignored, but the stored view may no longer make sense to your users. When you rename fields or rework a table's columns, change its state-key (for example, from orders to orders-v2) so returning users start fresh instead of restoring a view built for the old shape.

The search field, column toggle, status bar, and pagination live in an action bar above the table and a footer below it. Add your own controls with the actions, footer-start, and footer-end slots, which are useful for bulk-action buttons, export links, or a custom summary that sits alongside the built-in controls. The actions slot sits just after the search field; the column toggle stays anchored to the end.

Export Updated just now
<quiet-data-table label="Invoices" with-search id="data-table__slots">
  <quiet-button slot="actions">
    <quiet-icon slot="start" name="download"></quiet-icon>
    Export
  </quiet-button>
  <small slot="footer-end" style="color: var(--quiet-text-muted);">Updated just now</small>
</quiet-data-table>

<script>
  const grid = document.getElementById('data-table__slots');
  grid.columns = [
    { field: 'number', label: 'Invoice', flex: 1 },
    { field: 'client', label: 'Client', flex: 2 },
    { field: 'amount', label: 'Amount', align: 'end', flex: 1, render: value => `$${value.toLocaleString()}` }
  ];
  grid.data = [
    { number: 'INV-001', client: 'Acme', amount: 4200 },
    { number: 'INV-002', client: 'Globex', amount: 1850 },
    { number: 'INV-003', client: 'Initech', amount: 9600 }
  ];
</script>

The footer also shows a status bar with the total row count and, when rows are selected, a selection summary plus "Select all" and "Clear" actions. You can see it under every example on this page. Add the without-status-bar attribute to hide it; pagination still appears when enabled.

Column footers Jump to heading

Give a column an aggregate to show a summary in a footer row that sticks to the bottom of the table. The built-ins are sum, avg, min, max, count, and unique, computed over the filtered rows across every page. Use footer for a static label (like "Total") or a function for custom content. The footer row appears as soon as any column defines an aggregate or footer.

<quiet-data-table label="Shelter intake" id="data-table__footer"></quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__footer');

    grid.columns = [
      { field: 'name', label: 'Cat', flex: 1, footer: 'Total' },
      { field: 'breed', label: 'Breed', flex: 1, aggregate: 'count' },
      { field: 'treats', label: 'Treats/day', align: 'end', flex: 1, aggregate: 'sum' },
      { field: 'weight', label: 'Weight (kg)', align: 'end', flex: 1, aggregate: 'avg' }
    ];

    grid.data = [
      { name: 'Whiskers', breed: 'Tabby', treats: 4, weight: 4.2 },
      { name: 'Mittens', breed: 'Calico', treats: 6, weight: 3.8 },
      { name: 'Shadow', breed: 'Bombay', treats: 3, weight: 5.1 },
      { name: 'Pumpkin', breed: 'Maine Coon', treats: 5, weight: 6.7 }
    ];
  }
</script>

The built-in aggregates fall into two groups. sum, avg, min, and max coerce each cell to a number and ignore anything that isn't one, so they're meant for numeric columns. count (non-empty cells) and unique (distinct values) work on any column: text, dates, booleans, etc. A numeric result is localized automatically; anything else is shown as-is.

When the built-ins don't fit, set aggregate to a function instead of a keyword. It receives the column's values and the full rows and returns a string or number, so it isn't limited to numbers. Compute a median, the most common value, a joined list, or anything else:

{
  field: 'breed',
  label: 'Breed',
  // Show the most common breed rather than a numeric rollup
  aggregate: values => {
    const tally = {};
    for (const value of values) tally[value] = (tally[value] ?? 0) + 1;
    return Object.entries(tally).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '—';
  }
}

To control how the result is displayed, pair aggregate with a footer function. It receives the computed value along with the raw values and rows, and overrides the default display. A plain string footer (like footer: 'Total') is shown as a label, handy for naming the row in its first column.

{ 
  field: 'revenue', 
  label: 'Revenue', 
  align: 'end', 
  aggregate: 'sum', footer: ({ value }) => `$${value.toLocaleString()}` 
}

In manual (server-side) mode the footer only sees the rows you've loaded, so a built-in aggregate would total just the current page. Compute table-wide totals on the server and return them from a footer function that ignores the local values:

let totals = { revenue: 0 };

grid.columns = [
  { field: 'customer', label: 'Customer', footer: 'Total' },
  { field: 'revenue', label: 'Revenue', align: 'end', footer: () => `$${totals.revenue.toLocaleString()}` }
];

// Refresh the totals from the server whenever you load a page. The `page`
// property is already updated when the event fires.
grid.addEventListener('quiet-page-change', async () => {
  const data = await fetchOrders(grid.page);
  totals = data.totals; // e.g. { revenue: 1284500 }
  grid.data = data.rows;
});

Exporting to CSV Jump to heading

Call exportCsv() to download the table as a CSV, or getCsv() to get the CSV as a string, which you can upload somewhere instead. By default the export includes the filtered and sorted rows across every page and the visible columns, with fields escaped and the file encoded so spreadsheets open it cleanly. Put the trigger in the actions or a footer slot.

Rendered columns export their underlying value, so Amount below exports 4200.5 rather than $4,200.50. When the raw value isn't what you want, give the column an exportValue function: here Date is stored as a timestamp but exports an ISO date. Search to narrow the rows, then export to watch the current view come through.

Export CSV
<quiet-data-table label="Invoices" with-search id="data-table__csv">
  <quiet-button slot="actions" id="data-table__csv-button">
    <quiet-icon slot="start" name="download"></quiet-icon>
    Export CSV
  </quiet-button>
</quiet-data-table>

<script>
  const grid = document.getElementById('data-table__csv');
  const button = document.getElementById('data-table__csv-button');

  const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
  const dateFormat = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });
  const statusColor = {
    Paid: 'var(--quiet-constructive-text-colorful)',
    Pending: 'var(--quiet-text-muted)',
    Overdue: 'var(--quiet-destructive-text-colorful)'
  };

  grid.columns = [
    { field: 'order', label: 'Order #' },
    { field: 'customer', label: 'Customer' },
    { field: 'region', label: 'Region' },
    { field: 'rep', label: 'Sales rep' },
    {
      field: 'date',
      label: 'Date',
      align: 'end',
      render: value => dateFormat.format(value),
      exportValue: value => new Date(value).toISOString().slice(0, 10)
    },
    { field: 'amount', label: 'Amount', align: 'end', render: value => currency.format(value) },
    { field: 'status', label: 'Status', render: value => `<span style="color: ${statusColor[value]};">${value}</span>` }
  ];

  const records = [
    ['INV-1001', 'Acme Corp', 'North', 'Whiskers', '2026-01-14', 4200.5, 'Paid'],
    ['INV-1002', 'Globex', 'South', 'Mittens', '2026-01-22', 1850, 'Pending'],
    ['INV-1003', 'Initech', 'West', 'Shadow', '2026-02-03', 9600, 'Paid'],
    ['INV-1004', 'Umbrella', 'East', 'Cleo', '2026-02-11', 720.25, 'Overdue'],
    ['INV-1005', 'Soylent', 'North', 'Pumpkin', '2026-02-18', 3300, 'Paid'],
    ['INV-1006', 'Hooli', 'South', 'Whiskers', '2026-03-02', 5400.75, 'Pending']
  ];

  grid.data = records.map(([order, customer, region, rep, date, amount, status], i) => ({
    id: i + 1,
    order,
    customer,
    region,
    rep,
    date: Date.parse(`${date}T00:00:00Z`),
    amount,
    status
  }));

  button.addEventListener('click', () => grid.exportCsv({ filename: 'invoices.csv' }));
</script>

You can scope the export with options: rows is 'view' by default but can be 'all' (ignore filters) or 'selected', and columns can be 'all' to include hidden columns. Set includeHeaders: false to skip the header row, and set delimiter to change the field separator from the default ',': use '\t' for a TSV file or ';' for locales whose spreadsheet apps expect it.

Values that a spreadsheet might run as a formula (a leading =, +, -, or @) are neutralized by default; pass formulaGuard: false when the data is trusted and you need the raw values preserved. In manual (server-side) mode the table only holds the rows it has loaded, so export the full dataset on the server instead.

Copying to the clipboard Jump to heading

Call copy() to put rows on the clipboard as tab-separated values, which paste cleanly into spreadsheet apps. It copies the selected rows by default, falling back to the current view (the filtered and sorted rows across all pages) when nothing is selected. It accepts the same options as getCsv(), so pass { delimiter: ',' } for comma-separated text or { rows: 'all' } to ignore the active filters.

Users can also press + C while the table has focus and rows are selected. When text is selected, the browser's native copy wins instead, so users can still copy a snippet out of a cell. Select a few cats below, then use the button or the shortcut and paste the result into a spreadsheet.

Copy
<quiet-data-table label="Cats" selection="multiple" id="data-table__copy">
  <quiet-button slot="actions" id="data-table__copy-button">
    <quiet-icon slot="start" name="copy"></quiet-icon>
    Copy
  </quiet-button>
</quiet-data-table>

<script>
  {
    const grid = document.getElementById('data-table__copy');
    const button = document.getElementById('data-table__copy-button');

    grid.columns = [
      { field: 'name', label: 'Name', flex: 1 },
      { field: 'breed', label: 'Breed', flex: 1 },
      { field: 'naps', label: 'Naps/day', align: 'end', flex: 1 }
    ];

    grid.data = [
      { id: 1, name: 'Whiskers', breed: 'Tabby', naps: 7 },
      { id: 2, name: 'Mittens', breed: 'Calico', naps: 9 },
      { id: 3, name: 'Shadow', breed: 'Bombay', naps: 6 },
      { id: 4, name: 'Pumpkin', breed: 'Maine Coon', naps: 8 }
    ];

    button.addEventListener('click', () => grid.copy());
  }
</script>

Striped rows Jump to heading

Add the with-stripes attribute to draw alternating stripes that make rows easier to scan. Set the --stripe-background custom property to change the stripe color.

<quiet-data-table label="Elements" with-stripes id="data-table__striped"></quiet-data-table>

<style>
  #data-table__striped {
    --stripe-background: var(--quiet-primary-fill-softer);
  }
</style>

<script>
  const grid = document.getElementById('data-table__striped');
  grid.columns = [
    { field: 'symbol', label: 'Symbol', flex: 1 },
    { field: 'name', label: 'Name', flex: 2 },
    { field: 'number', label: 'Number', align: 'end', flex: 1 }
  ];
  grid.data = [
    { symbol: 'H', name: 'Hydrogen', number: 1 },
    { symbol: 'He', name: 'Helium', number: 2 },
    { symbol: 'Li', name: 'Lithium', number: 3 },
    { symbol: 'Be', name: 'Beryllium', number: 4 },
    { symbol: 'B', name: 'Boron', number: 5 }
  ];
</script>

Changing the appearance Jump to heading

Set the appearance attribute to unstyled for a simpler look you can style from scratch. This strips the outer frame, drop shadow, row separators, header underline, and pinned-column seams while keeping the header's column dividers; row hover and selection still highlight. For finer control, set the --border-color and --divider-color custom properties (use transparent to hide a set of lines).

<quiet-data-table label="Elements" appearance="unstyled" id="data-table__borderless"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__borderless');
  grid.columns = [
    { field: 'symbol', label: 'Symbol', flex: 1 },
    { field: 'name', label: 'Name', flex: 2 },
    { field: 'number', label: 'Number', align: 'end', flex: 1 }
  ];
  grid.data = [
    { symbol: 'H', name: 'Hydrogen', number: 1 },
    { symbol: 'He', name: 'Helium', number: 2 },
    { symbol: 'Li', name: 'Lithium', number: 3 },
    { symbol: 'Be', name: 'Beryllium', number: 4 }
  ];
</script>

Changing the size Jump to heading

Set the size attribute to xs, sm, md, lg, or xl to scale the table's density. Everything responds, including the action bar, search field, selection checkboxes, pagination, and the form controls inside the column filter and menu popups. Use the select below to preview each size.

<quiet-select label="Size" value="xs" id="data-table__size-select" style="max-width: 12rem; margin-block-end: 1.5rem;">
  <option value="xs">Extra small</option>
  <option value="sm">Small</option>
  <option value="md">Medium</option>
  <option value="lg">Large</option>
  <option value="xl">Extra large</option>
</quiet-select>

<quiet-data-table
  label="Cats"
  size="xs"
  selection="multiple"
  page-size="5"
  with-search
  with-column-toggle
  with-column-menu
  paginate
  id="data-table__size"
></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__size');
  const select = document.getElementById('data-table__size-select');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1, filterable: true, filterType: 'text' },
    { field: 'breed', label: 'Breed', flex: 1, filterable: true },
    { field: 'status', label: 'Status', flex: 1, filterable: true },
    { field: 'age', label: 'Age', align: 'end', flex: 1, filterable: true, filterType: 'number' }
  ];

  grid.data = [
    { id: 1, name: 'Whiskers', breed: 'Tabby', status: 'Adopted', age: 3 },
    { id: 2, name: 'Mittens', breed: 'Calico', status: 'Available', age: 2 },
    { id: 3, name: 'Shadow', breed: 'Bombay', status: 'Adopted', age: 5 },
    { id: 4, name: 'Cleo', breed: 'Siamese', status: 'Available', age: 1 },
    { id: 5, name: 'Pumpkin', breed: 'Tabby', status: 'Fostered', age: 4 },
    { id: 6, name: 'Biscuit', breed: 'Maine Coon', status: 'Adopted', age: 6 }
  ];

  select.addEventListener('quiet-change', () => {
    grid.size = select.value;
  });
</script>

Responsive layouts Jump to heading

A table wider than its container scrolls horizontally, which is often all a small screen needs. To go further, hide lower-priority columns when space is tight: watch the table's width and reassign columns with hidden set on the ones to drop. Pair it with with-column-toggle so users can bring them back. Narrow the window to watch the email and phone columns come and go.

<quiet-data-table label="Contacts" with-column-toggle id="data-table__responsive"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__responsive');

  let narrow = null;

  function applyColumns(isNarrow) {
    grid.columns = [
      { field: 'name', label: 'Name', flex: 1, hideable: false },
      { field: 'email', label: 'Email', flex: 2, hidden: isNarrow },
      { field: 'phone', label: 'Phone', flex: 1, hidden: isNarrow },
      { field: 'role', label: 'Role', flex: 1 }
    ];
  }

  // React to the table's own width rather than the viewport's, and only rebuild when crossing the threshold
  const observer = new ResizeObserver(([entry]) => {
    const isNarrow = entry.contentRect.width < 640;
    if (isNarrow === narrow) return;
    narrow = isNarrow;
    applyColumns(isNarrow);
  });
  observer.observe(grid);

  grid.data = [
    { name: 'Whiskers', email: 'whiskers@example.com', phone: '555-0100', role: 'Owner' },
    { name: 'Mittens', email: 'mittens@example.com', phone: '555-0101', role: 'Admin' },
    { name: 'Shadow', email: 'shadow@example.com', phone: '555-0102', role: 'Editor' }
  ];
</script>

Styling with CSS parts Jump to heading

Use CSS parts and custom properties to customize the table's appearance.

<quiet-data-table label="Styled" id="data-table__styling"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__styling');
  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'role', label: 'Role', flex: 1 }
  ];
  grid.data = [
    { name: 'Whiskers', role: 'Engineer' },
    { name: 'Mittens', role: 'Architect' }
  ];
</script>

<style>
  #data-table__styling {
    --header-background: var(--quiet-primary-fill-softer);
    --border-color: var(--quiet-primary-stroke-soft);

    &::part(header-cell) {
      color: var(--quiet-primary-text-colorful);
    }
  }
</style>

Styling exported parts Jump to heading

Some parts of the table are other Quiet components, such as the search field and the pagination control. These components export their own parts with a double underscore, so you can reach inside them from page CSS. For example, search__visual-box targets the search field's visual-box part and pagination__button-current targets the current page's button. Every exported part is listed in the API section below.

<quiet-data-table
  label="Adoptable cats"
  with-search
  paginate
  page-size="3"
  id="data-table__exported-parts"
></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__exported-parts');
  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'breed', label: 'Breed', flex: 1 }
  ];
  grid.data = [
    { name: 'Whiskers', breed: 'Tabby' },
    { name: 'Mittens', breed: 'Siamese' },
    { name: 'Shadow', breed: 'Bombay' },
    { name: 'Patches', breed: 'Calico' },
    { name: 'Biscuit', breed: 'Persian' },
    { name: 'Clover', breed: 'Maine Coon' }
  ];
</script>

<style>
  #data-table__exported-parts {
    &::part(search__visual-box) {
      border-radius: var(--quiet-border-radius-pill);
    }

    &::part(pagination__button-current) {
      background-color: var(--quiet-primary-fill-mid);
      color: var(--quiet-primary-text-on-mid);
    }
  }
</style>

Targeting a single column Jump to heading

Every header and body cell carries a data-field attribute set to its column's field, but page CSS can't combine it with ::part(): an attribute selector isn't allowed after a part selector, so a rule like ::part(cell)[data-field='total'] never matches. To style one column's cells, use the column's cellStyle option instead. It's the same hook conditional styling uses, and returning a constant object styles every cell in the column. Here the total column's cells are emphasized.

<quiet-data-table label="Sales" id="data-table__data-field"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__data-field');
  grid.columns = [
    { field: 'product', label: 'Product', flex: 2 },
    { field: 'units', label: 'Units', align: 'end', flex: 1 },
    {
      field: 'total',
      label: 'Total',
      align: 'end',
      flex: 1,
      render: value => `$${value.toLocaleString()}`,
      // A constant style object applies to every cell in this column
      cellStyle: () => ({
        fontWeight: 'var(--quiet-font-weight-semibold)',
        color: 'var(--quiet-primary-text-colorful)'
      })
    }
  ];
  grid.data = [
    { product: 'Widget', units: 120, total: 1440 },
    { product: 'Gadget', units: 80, total: 1600 },
    { product: 'Gizmo', units: 200, total: 900 }
  ];
</script>

For rules that need real CSS (hover states, or a rendered component's parts), adopt a stylesheet into the table's shadow root as shown in styling rendered components. Inside the shadow root, a selector like td[data-field='total'] matches directly.

Conditional styling Jump to heading

CSS parts handle static, structural styling well, but they can't react to a cell's value: rendered content lives in the table's shadow DOM, and a row's data isn't reflected to any attribute a stylesheet could target. For data-driven styling, return inline styles from a column's cellStyle (for one column's cells) or the table's getRowStyle (for the whole row). Each returns an object of CSS properties. Use design tokens so the result stays on-theme, and return a falsy value to leave the cell or row unstyled.

Here cellStyle flags cats that have waited a long time, and getRowStyle tints rows for cats that have already been adopted.

<quiet-data-table label="Shelter cats" id="data-table__conditional-style"></quiet-data-table>

<script>
  const grid = document.getElementById('data-table__conditional-style');

  grid.columns = [
    { field: 'name', label: 'Name', flex: 1 },
    { field: 'breed', label: 'Breed', flex: 1 },
    {
      field: 'daysWaiting',
      label: 'Days waiting',
      align: 'end',
      flex: 1,
      // Receives the cell's value, the full row, and the column
      cellStyle: value => {
        if (value >= 60) {
          return {
            color: 'var(--quiet-destructive-text-colorful)',
            fontWeight: 'var(--quiet-font-weight-semibold)'
          };
        }
        if (value >= 30) {
          return { color: 'var(--quiet-destructive-text-colorful)' };
        }
        return null;
      }
    }
  ];

  // Receives the full row and its index; tint the whole row once a cat is adopted
  grid.getRowStyle = row => {
    return row.adopted ? { background: 'var(--quiet-constructive-fill-softer)' } : null;
  };

  grid.data = [
    { name: 'Whiskers', breed: 'Tabby', daysWaiting: 72, adopted: false },
    { name: 'Mittens', breed: 'Siamese', daysWaiting: 12, adopted: true },
    { name: 'Pumpkin', breed: 'Maine Coon', daysWaiting: 41, adopted: false },
    { name: 'Shadow', breed: 'Bombay', daysWaiting: 5, adopted: true }
  ];
</script>

Keyboard support Jump to heading

Tab to the table's body to focus it, then use the keys below. The body is a single tab stop; the header's sort buttons, filter buttons, and column menus are regular tab stops before it.

Key Action
Moves the focused cell up or down a row
Moves the focused cell to the previous or next column
Page Up / Page Down Moves the focused cell up or down a page of rows
Home / End Moves the focused cell to the first or last column in the row
+ Home / End Moves the focused cell to the first or last cell of the table
Enter / F2 Steps into the focused cell's interactive content, focusing the first link, button, or editor
Escape / F2 Steps back out to the cell, restoring arrow-key navigation
Space Toggles selection when the focused cell is in a selectable row
+ Space Extends the selection from the last toggled row to the focused row
Enter / Space Toggles the detail panel when the focused cell is the row's expansion toggle
Enter / Space Toggles the group when the focused cell is in a group row
+ C Copies the selected rows to the clipboard (when no text is selected)

While inside a cell's content, Tab cycles through that cell's interactive elements (it doesn't leave the cell); Escape or F2 returns to the table. In right-to-left layouts, the horizontal arrows follow the reading direction.

API Jump to heading

Importing Jump to heading

The autoloader is the recommended way to import components but, if you prefer to do it manually, the following code snippets will be helpful.

CDN Self-hosted

To manually import <quiet-data-table> from the CDN, use the following code.

import 'https://cdn.quietui.org/v6.0.0/components/data-table/data-table.js';

To manually import <quiet-data-table> from a self-hosted distribution, use the following code. Remember to replace /path/to/quiet with the appropriate local path.

import '/path/to/quiet/components/data-table/data-table.js';

Slots Jump to heading

Data Table supports the following slots. Learn more about using slots

Name Description
actions Custom content shown after the search field (e.g. bulk-action or export buttons).
empty Content shown when there are no rows to display.
loading Custom content shown in the loading overlay (replaces the default spinner).
footer-start Custom content shown at the start of the footer.
footer-end Custom content shown at the end of the footer.

Properties Jump to heading

Data Table has the following properties that can be set with corresponding attributes. In many cases, the attribute's name is the same as the property's name. If an attribute is different, it will be displayed after the property. Learn more about attributes and properties

Property Description Reflects Type Default
data The rows to display. Each row is a plain object keyed by column field. DataTableRow[] []
columns Column definitions. When omitted, columns are inferred from the keys of the first row. Entries may be leaf columns or DataTableColumnGroups, which nest child columns under a banded group header. DataTableColumns | undefined
getRowId A function that returns a stable, unique key for a row. When omitted, the grid uses the row's id or key property if present, falling back to the row's index. Providing this (or an id/key field) is recommended so selection stays correct across sorting, filtering, and pagination, and is required in manual mode. (row: DataTableRow, index: number) => string | undefined
selection The selection mode. 'none' | 'single' | 'multiple' 'none'
selectedKeys The keys of the currently selected rows. string[] []
isRowSelectable Limits which rows can be selected. Return false to make a row unselectable: its checkbox renders disabled and it's skipped by the header checkbox, "Select all", selectAll(), and Shift-range selection. This gates the selection UI only — keys assigned to selectedKeys programmatically are left as-is. (row: DataTableRow) => boolean | undefined
renderRowDetail Renders an expandable detail panel for a row. When set, each row gets a disclosure toggle in a leading column and can expand to reveal the returned content in a full-width panel beneath it. Receives the full row and may return an HTML string (rendered as-is, so only use trusted content), a DOM node, or a Lit template. (row: DataTableRow) => string | Node | TemplateResult | undefined
getRowStyle Applies inline styles to each body row, driven by its data. Receives the full row and its view index, and returns an object of CSS properties (camelCase or kebab-case keys, plus --custom-properties) to set on the <tr>, or a falsy value to leave it unstyled. Use this for whole-row treatments (e.g. tinting overdue rows) that a page stylesheet can't express, since the row's data isn't reflected to an attribute it could target. Use design tokens for values (e.g. { background: 'var(--quiet-destructive-fill-softer)' }). Style a single column's cells from its cellStyle instead. Applies to center body rows, not pinned, detail, or footer rows (style those via their parts). ( row: DataTableRow, index: number ) => Record<string, string> | null | undefined | undefined
expandedKeys The keys of the currently expanded rows. string[] []
singleExpand
single-expand
Allows only one row's detail panel to be open at a time. Expanding a row collapses any other. boolean false
pinnedRows Pins rows to the top and/or bottom of the table, keyed by the same id strategy as getRowId. Pinned rows stay visible while the center body scrolls and persist across pages — handy for a summary or other always-visible row. Keys that don't match a row are ignored. In manual mode a pinned row must be present in the loaded data to show. { top?: string[]; bottom?: string[] } {}
groupBy
group-by
Groups rows by one or more column fields, each group led by a collapsible header row showing the group's value, its row count, and any per-column aggregates. Pass an array of fields for nested groups, or set the group-by attribute to a space- or comma-separated list. While grouped, the grouped column is hidden from the table (its value lives on the group rows), pagination and pinned rows are disabled, and the grid exposes itself as a tree grid. Has no effect in manual mode, where grouping is the server's job. string[] []
sortField
sort-field
The primary sort column's field. Mirrors the first entry of sort for convenient single-column sorting. string ''
sortDirection
sort-direction
The primary sort direction. Mirrors the first entry of sort. 'asc' | 'desc' 'asc'
sort The full multi-column sort, in priority order. This is the canonical sort state; sort-field/sort-direction mirror its primary (first) entry. Shift-click a header to add secondary sorts. { field: string; direction: 'asc' | 'desc' }[] []
page The current page when pagination is enabled (1-based). number 1
pageSize
page-size
The number of rows to show per page. number 25
paginate Enables pagination. boolean false
query The global search query. Matches per match; rows are kept when any searchable cell matches. string ''
match How the global search — the query property and the search field — matches rows. 'exact' (the default) keeps rows whose value contains the query. 'fuzzy' is forgiving of typos and transpositions. 'custom' defers to the isMatch function. Has no effect on column filters or in manual mode, where the server does the filtering. 'exact' | 'fuzzy' | 'custom' 'exact'
isMatch A custom matcher for the global filter, used when match is 'custom'. Receives the query, the cell's text, and the full row, and returns true when the row should be kept. A row matches when any of its cells matches. (query: string, content: string, row: DataTableRow) => boolean | undefined
withSearch
with-search
Shows a search field in the action bar that controls the global filter. boolean false
searchPlaceholder
search-placeholder
Placeholder text for the global search field. Defaults to a localized "Search". string ''
withColumnToggle
with-column-toggle
Shows a dropdown in the action bar for toggling column visibility. boolean false
withColumnMenu
with-column-menu
Shows a menu button in each column header with sort, autosize, pin, and hide actions. boolean false
resizable Allows columns to be resized by dragging their edges. Can be overridden per column. boolean false
reorderable Allows columns to be reordered by dragging their headers. boolean false
manual Enables server-side mode. The grid will not sort, filter, or paginate data itself. Instead, it emits events so you can fetch and supply the processed rows. Provide total-rows for pagination. boolean false
totalRows
total-rows
The total number of rows across all pages. Required for pagination in manual mode. number | undefined
size The grid's size. 'xs' | 'sm' | 'md' | 'lg' | 'xl' 'md'
withStripes
with-stripes
Draws alternating row stripes to make rows easier to scan. boolean false
appearance The grid's appearance. Use normal for the default framed look or unstyled to strip the outer frame, borders, and drop shadow for a cleaner, lighter look. Row hover and selection still highlight. 'normal' | 'unstyled' 'normal'
withoutStatusBar
without-status-bar
Hides the footer status bar (row count and selection summary). Pagination still shows when enabled. boolean false
loading Shows a loading overlay over the grid. boolean false
label The grid's accessible label, applied to the underlying table. string ''
stateKey
state-key
Persists the user's view — sorting, search query, column filters, column visibility/order/widths/pinning, and pagination — to localStorage under this key, restoring it when the grid connects. Use a unique key per grid. Selection and expansion are not persisted. To store the state somewhere else (e.g. server-side), listen for quiet-state-change and reapply it later with restoreViewState(). string ''
selectedRows The currently selected rows. DataTableRow[]
expandedRows The currently expanded rows, in data order. DataTableRow[]

Methods Jump to heading

Data Table supports the following methods. You can obtain a reference to the element and call them like functions in JavaScript. Learn more about methods

Name Description Arguments
selectAll() Selects all rows in the data set (skipping any isRowSelectable excludes). Only applies when selection is multiple.
clearSelection() Clears the current selection.
clearFilters() Clears every column filter and the global search filter.
getViewState() Returns a serializable snapshot of the current view state — sorting, search query, column filters, column visibility/order/widths/pinning, and pagination. Store it anywhere (it's plain JSON) and reapply it later with restoreViewState(). Selection and expansion are not included.
restoreViewState() Reapplies a view state produced by getViewState(). Missing slices are left as they are. Restored column widths count as user-sized, so auto-sizing won't overwrite them. Restoring emits no events (like direct property assignment; unlike action methods such as clearFilters() or selectAll(), which do). state: DataTableViewState
showAllColumns() Makes every hidden column visible again.
getCsv() Builds a CSV from the table's rows and returns it as a string. By default it exports the filtered and sorted rows across all pages, using the visible columns and their labels. Each cell's value comes from the column's exportValue function, or the raw cell value when none is set — never the rendered cell. Fields are escaped per RFC 4180, and values a spreadsheet might evaluate as a formula are neutralized unless formulaGuard is false. The returned string has no byte-order mark, so it's ready to send to a server or process further; use exportCsv() to download a spreadsheet-friendly file. In manual (server-side) mode the table only holds the rows you've loaded, so the export reflects those rows. For the full dataset, export it on the server. options: DataTableCsvOptions
exportCsv() Builds a CSV with getCsv() and downloads it as a file. The file is encoded as UTF-8 with a byte-order mark so spreadsheet apps read non-ASCII characters correctly. options: DataTableExportOptions
copy() Copies rows to the clipboard as tab-separated values, which paste cleanly into spreadsheet apps. By default it copies the selected rows, falling back to the current view when nothing is selected; pass any getCsv() options to override (e.g. { delimiter: ',' } for CSV, { rows: 'all' } for everything). Also bound to Cmd/Ctrl+C while the grid has focus and rows are selected. options: DataTableCsvOptions
expandRow() Expands a row's detail panel. With singleExpand, this collapses any other open row first. key: string
collapseRow() Collapses a row's detail panel. key: string
toggleRow() Toggles a row's detail panel. key: string
expandAll() Expands every row's detail panel. No-op when singleExpand is set (only one row may be open).
collapseAll() Collapses every row's detail panel.
expandAllGroups() Expands every group when rows are grouped via groupBy.
collapseAllGroups() Collapses every group when rows are grouped via groupBy.
moveColumn() Moves a column to a specific index in the column order. field: string, toIndex: number
autoSizeColumn() Sizes a single column to fit its content and commits the new width, overriding any explicit width or prior hand resize. Backs the double-click-to-fit gesture and the column menu's "autosize" action, and is available publicly so authors can fit a column on demand. Measurement is bounded by the rendered (virtualized) rows. field: string
autoSizeAllColumns() Sizes every column to fit its content, overriding explicit widths and prior hand resizes. Backs the Cmd/Shift double-click-to-fit-all gesture on a resize handle, and is available publicly so authors can fit the whole grid on demand. Measurement is bounded by the rendered (virtualized) rows.

Events Jump to heading

Data Table dispatches the following custom events. You can listen to them the same way was native events. Learn more about custom events

Name Description
quiet-sort-change Emitted when the sort column(s) or direction changes.
quiet-row-selection-change Emitted when the row selection changes.
quiet-expand-change Emitted when a row's detail panel is expanded or collapsed.
quiet-filter-change Emitted when the global filter or a column filter changes.
quiet-page-change Emitted when the current page changes.
quiet-column-resize Emitted after a column is resized.
quiet-column-reorder Emitted after a column is reordered.
quiet-row-click Emitted when a row is clicked.
quiet-state-change Emitted (debounced) when the view state changes and a state-key is set. The detail carries the same snapshot getViewState() returns.

CSS custom properties Jump to heading

Data Table supports the following CSS custom properties. You can style them like any other CSS property. Learn more about CSS custom properties

Name Description Default
--row-height The height of each row. A fixed value (e.g. 3.25rem) yields the smoothest scrolling for large data sets. auto
--row-font-size The font size of body cells, relative to the grid's font size. 0.9375em
--max-height Constrains the scrollable body's height. Set this (or otherwise bound the grid's height) to enable row virtualization for large data sets. none
--cell-padding-block The block padding of each cell. 0.875em
--cell-padding-inline The inline padding of each cell. 1.25em
--header-background The background color of the header.
--border-color The color of cell borders.
--divider-color The color of the column dividers in the header.
--selected-background The background color of selected rows.
--stripe-background The background color of alternating rows when with-stripes is set.
--group-row-background The background color of group header rows when rows are grouped.

CSS parts Jump to heading

Data Table exposes internal elements that can be styled with CSS using the selectors shown below. Learn more about CSS parts

Name Description CSS selector
actions The bar above the table that holds the search field, custom actions, and column toggle. ::part(actions)
search The global search field, a <quiet-text-field> element. ::part(search)
search__visual-box The search field's visual-box part. ::part(search__visual-box)
search__text-box The search field's text-box part. ::part(search__text-box)
search__clear-button The search field's clear-button part. ::part(search__clear-button)
column-toggle The column visibility dropdown, a <quiet-dropdown> element. ::part(column-toggle)
column-toggle__menu The column visibility dropdown's menu part. ::part(column-toggle__menu)
column-toggle-button The button that opens the column visibility dropdown, a <quiet-button> element. ::part(column-toggle-button)
column-toggle-button__button The column toggle button's button part. ::part(column-toggle-button__button)
column-toggle-item A column's checkbox item in the column visibility dropdown, a <quiet-dropdown-item> element. ::part(column-toggle-item)
scroll-container The scrollable container that wraps the table. ::part(scroll-container)
table The <table> element. ::part(table)
header The <thead> element. ::part(header)
header-row The header <tr> element. ::part(header-row)
header-cell A header <th> element. ::part(header-cell)
group-header A banded (group) header cell spanning its child columns; also carries header-cell. ::part(group-header)
placeholder-cell An empty header cell filling the space above ungrouped columns in a banded header row; also carries header-cell. ::part(placeholder-cell)
header-label The text label inside a header cell, a group header, or a sort button. ::part(header-label)
sort-button The sort button inside a sortable header cell. ::part(sort-button)
sort-icon The sort indicator icon, a <quiet-icon> element. ::part(sort-icon)
sort-number The priority badge shown on a header during a multi-column sort. ::part(sort-number)
pin-button The pushpin button shown in a pinned column's header; click it to unpin the column. ::part(pin-button)
resize-handle The drag handle used to resize a column. An unexposed overlay widens the grab area around it, so avoid changing this part's width or offset. ::part(resize-handle)
resize-limit The full-height line flashed at a column's trailing edge when a resize drag hits its minWidth or maxWidth. ::part(resize-limit)
filter-button The funnel button in a filterable column's header. ::part(filter-button)
filter-menu The popover panel containing a column's filter. ::part(filter-menu)
filter-options The scrollable list of values in a faceted filter menu. ::part(filter-options)
filter-option A value's checkbox in a faceted filter menu, a <quiet-checkbox> element. ::part(filter-option)
filter-select-all The select-all checkbox at the top of a faceted filter menu; also carries filter-option. ::part(filter-select-all)
filter-blank The "(Blank)" label standing in for an empty value in a faceted filter menu. ::part(filter-blank)
filter-empty The message shown when a faceted filter's search matches no values. ::part(filter-empty)
filter-overflow The note shown when a faceted filter's value list is capped. ::part(filter-overflow)
filter-range-separator The dash between the two inputs of a range or date-range filter. ::part(filter-range-separator)
filter-search The search field in a set filter's menu, a <quiet-text-field> element. ::part(filter-search)
filter-search__visual-box The filter search field's visual-box part. ::part(filter-search__visual-box)
filter-search__text-box The filter search field's text-box part. ::part(filter-search__text-box)
filter-input The input of a text or number filter, a <quiet-text-field> or <quiet-number-input> element. ::part(filter-input)
filter-input__visual-box The filter input's visual-box part. ::part(filter-input__visual-box)
filter-input__text-box The filter input's text-box part. ::part(filter-input__text-box)
filter-input-min The minimum input of a range or date-range filter, a <quiet-number-input> or <quiet-date-input> element. ::part(filter-input-min)
filter-input-min__visual-box The minimum filter input's visual-box part. ::part(filter-input-min__visual-box)
filter-input-min__text-box The minimum filter input's text-box part (range filters only). ::part(filter-input-min__text-box)
filter-input-max The maximum input of a range or date-range filter, a <quiet-number-input> or <quiet-date-input> element. ::part(filter-input-max)
filter-input-max__visual-box The maximum filter input's visual-box part. ::part(filter-input-max__visual-box)
filter-input-max__text-box The maximum filter input's text-box part (range filters only). ::part(filter-input-max__text-box)
column-menu-button The menu (kebab) button in a column's header. ::part(column-menu-button)
column-menu The column actions menu, a <quiet-dropdown> element. ::part(column-menu)
column-menu__menu The column actions menu's menu part. ::part(column-menu__menu)
column-menu-item An action in the column actions menu, a <quiet-dropdown-item> element. ::part(column-menu-item)
body The <tbody> element. ::part(body)
row A body <tr> element. ::part(row)
cell A body <td> element. ::part(cell)
selection-cell The selection column's header and body cells; also carries header-cell or cell, respectively. The header cell holds a checkbox only when selection is multiple. ::part(selection-cell)
selection-checkbox A selection checkbox (the header's select-all or a row's), a <quiet-checkbox> element. ::part(selection-checkbox)
selection-checkbox__visual-box The selection checkbox's visual-box part. ::part(selection-checkbox__visual-box)
selection-checkbox__check-icon The selection checkbox's check-icon part. ::part(selection-checkbox__check-icon)
selection-checkbox__indeterminate-icon The selection checkbox's indeterminate-icon part. ::part(selection-checkbox__indeterminate-icon)
expansion-cell The expansion column's header and body cells; also carries header-cell or cell, respectively. The body cell holds the detail-panel disclosure toggle and the header cell is empty. ::part(expansion-cell)
expand-button The disclosure toggle button inside an expansion cell. ::part(expand-button)
detail-row The full-width <tr> that holds an expanded row's detail panel. ::part(detail-row)
detail-cell The <td> inside a detail row that holds the rendered detail content. ::part(detail-cell)
column-footer The <tfoot> element, shown when any column defines an aggregate or footer (distinct from the footer status bar below the table). ::part(column-footer)
footer-row The <tr> inside the column footer. ::part(footer-row)
footer-cell A <td> in the footer row, holding a column's aggregate or custom footer content. ::part(footer-cell)
group-row A collapsible group header row shown while rows are grouped via groupBy; also carries row. ::part(group-row)
group-cell A <td> in a group header row; also carries cell. ::part(group-cell)
group-toggle The disclosure button at the start of a group row's heading. ::part(group-toggle)
group-label The group's value in a group header row. ::part(group-label)
group-count The leaf-row count shown beside a group's value. ::part(group-count)
pinned-row A row pinned to the top or bottom via pinnedRows. Top and bottom rows share this part and can't be told apart from page CSS, so use getRowStyle to style one or the other. ::part(pinned-row)
pinned-cell A <td> in a pinned row. ::part(pinned-cell)
filler-cell The trailing presentational cell (in the header row, the footer row, and every body, group, and pinned row) that absorbs slack when the columns are narrower than the grid, so the grid surface runs edge-to-edge. ::part(filler-cell)
empty The container shown when there are no rows, or when every data column is hidden. ::part(empty)
empty-state The default empty content inside empty, a <quiet-empty-state> element. Replaced when you slot your own content into empty. ::part(empty-state)
empty-state__content The empty state's content part. ::part(empty-state__content)
empty-state__illustration The empty state's illustration part. ::part(empty-state__illustration)
loading The loading overlay. ::part(loading)
spinner The default spinner inside the loading overlay, a <quiet-spinner> element. Replaced when you slot your own content into loading. ::part(spinner)
spinner__track The spinner's track part. ::part(spinner__track)
spinner__indicator The spinner's indicator part. ::part(spinner__indicator)
footer The footer below the table. ::part(footer)
footer-start The footer's leading track, holding the footer-start slot and the status bar. ::part(footer-start)
footer-end The footer's trailing track, holding the footer-end slot and the pagination. ::part(footer-end)
row-count The row count shown in the footer status bar. ::part(row-count)
selection-count The selection summary shown in the footer status bar. ::part(selection-count)
select-all-button The "select all" button in the footer status bar, a <quiet-button> element. ::part(select-all-button)
select-all-button__button The select-all button's button part. ::part(select-all-button__button)
clear-selection-button The "clear selection" button in the footer status bar, a <quiet-button> element. ::part(clear-selection-button)
clear-selection-button__button The clear selection button's button part. ::part(clear-selection-button__button)
pagination The pagination component, a <quiet-pagination> element. ::part(pagination)
pagination__nav The pagination's nav part. ::part(pagination__nav)
pagination__list The pagination's list part. ::part(pagination__list)
pagination__item The pagination's item part. ::part(pagination__item)
pagination__button The pagination's button part. ::part(pagination__button)
pagination__button-first The pagination's button-first part. ::part(pagination__button-first)
pagination__button-previous The pagination's button-previous part. ::part(pagination__button-previous)
pagination__button-next The pagination's button-next part. ::part(pagination__button-next)
pagination__button-last The pagination's button-last part. ::part(pagination__button-last)
pagination__button-page The pagination's button-page part. ::part(pagination__button-page)
pagination__button-current The pagination's button-current part. ::part(pagination__button-current)
pagination__button-jump-backward The pagination's button-jump-backward part. ::part(pagination__button-jump-backward)
pagination__button-jump-forward The pagination's button-jump-forward part. ::part(pagination__button-jump-forward)
pagination__range The pagination's range part. ::part(pagination__range)

Custom States Jump to heading

Data Table has the following custom states. You can target them with CSS using the selectors shown below. Learn more about custom states

Name Description CSS selector
empty Applied when there are no rows to display. :state(empty)
loading Applied when the loading property is set. :state(loading)
resizing Applied while a column is being resized by dragging. :state(resizing)
reordering Applied while a column is being dragged to a new position. :state(reordering)
grouped Applied while rows are grouped via groupBy. :state(grouped)
virtualized Applied while the body is virtualizing its rows (the body scrolls a large data set). :state(virtualized)
defer-offscreen Applied when the grid renders enough rows to defer its off-screen layout/paint with content-visibility (light grids opt out to avoid its per-viewport-change cost). :state(defer-offscreen)

Dependencies Jump to heading

Data Table automatically imports the following elements. Sub-dependencies are also included in this list.

Search this website Toggle dark mode View the code on GitHub Follow @quietui.org on Bluesky Follow @quiet_ui on X

    No results found