Components · Tables and data viz

Editable table

A grid of inline-editable cells for bulk content edits. Built for the moment a merchant has thirty menu items to retag, an admin imports a hundred rows of fees, or a designer is reviewing pricing across a category. Stays a real table on desktop; collapses into one card per row on mobile.

Documentedby Derek Fidler

Overview

The editable table is the canonical surface for bulk content edits — when the user needs to scan a column of values and change several at once without losing place. Every cell is a focused input; tabbing across a row feels like filling out a spreadsheet. Five column types: text, number, currency, select, read-only.

Bulk edits, not row dialogs

Use this when the operator's job is “sweep across a list and tweak many values”. For single-row creates and edits — picking one prototype and filling out a long form — use the standard TextField + Section form pattern instead. The editable table is dense by design.

Try it

Click into any cell, type, Tab to move across. The VAT column is a select — open it to see the options. Hit the trash icon at the end of any row to remove it; click “Add new row” below to append.

Row 1

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 2

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 3

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 4

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 5

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 6

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 7

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Try it

Click any cell to edit, Tab to move across, Enter to commit. The VAT column is a select — open it to see the options. Add or delete rows from the affordances.

Mobile

On phones, a 7-column grid becomes useless — labels truncate, taps land on the wrong cell, and horizontal scroll loses the user's place. Below md the component collapses each row into a stacked card with labelled fields, so the merchant edits one row at a time without leaving the page. The desktop table re-appears at md and up — same data, two surfaces, one source of truth.

Row 1

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 2

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 3

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Mobile — stacked cards

Below md, every row collapses into a labelled card. Editing one row at a time fits a phone better than a 7-column scroll. The desktop table re-appears at md and up.

Why cards, not a horizontal table

We considered keeping the table and letting it scroll horizontally on mobile — but with seven columns at a phone width, each cell ends up too narrow to type into without zooming. The card layout treats one row as one form, which is what the user is actually doing on a phone anyway. Saves a tap, fits the keyboard, no horizontal pan.

Read-only cells

Mark identifying columns as type="readonly" when they shouldn't be editable inline — SKUs, IDs, anything system-generated. They render as plain text but stay aligned with the editable cells around them.

Row 1

SKU
STR-GRK-01
Product
Greek salad
Sales price
VAT
Cost price

Row 2

SKU
STR-BRU-01
Product
Bruschetta
Sales price
VAT
Cost price

Row 3

SKU
MAI-MAR-01
Product
Margherita
Sales price
VAT
Cost price

Row 4

SKU
MAI-CAR-01
Product
Carbonara
Sales price
VAT
Cost price

Row 5

SKU
MAI-BAV-01
Product
Bavette steak
Sales price
VAT
Cost price

Mixed read-only + editable

SKU and Product are read-only — the row identity. Pricing fields are editable. Use the readonly type when the value is system-set or already validated upstream.

Empty state

Pass an emptyState string or node to teach the user how to start — what kind of data goes here, what the first row looks like. Pair it with a newRow factory so the affordance to add the first row is one click away.

No items yet. Add a row below to start the menu.

Empty state

The empty state should teach the user how to start — drop the seed instructions inside emptyState and pair it with the add-row affordance.

Anatomy

Five named parts. The header carries the column names; every cell is its own focusable input; the trash column and add-row link are separate affordances at the end.

Row 1

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 2

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 3

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 4

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 5

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 6

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Row 7

Category
Product name
Sales price
VAT
Cost price
Barcode
SKU

Try it

Click any cell to edit, Tab to move across, Enter to commit. The VAT column is a select — open it to see the options. Add or delete rows from the affordances.

  1. Container

    Rounded-lg surface with a 1 px #EEEEEF border. Carries horizontal scroll on narrow desktops; mobile drops the container and renders cards instead.

  2. Header row

    Subtler grey background (#F8F8F8), Inter Tight Medium 13/20 in text.tertiary. Sits at the top of the surface and stays visually separate from data rows.

  3. Cell

    Each cell is a focused input. Five types — text / number / currency / select / readonly. Focus shows the system #9EB7FF ring inset to the cell, so the cell itself reads as the edit surface.

  4. Delete column

    A trash icon per row, hover-darkens to the danger tone. Optional via allowDelete; hidden when reads only.

  5. Add row affordance

    Standalone link below the table. Calls newRow() to append a fresh row with default values, then the user types straight into the new row.

Behavior

  • Tab moves across, Shift+Tab back. Native input focus order — no custom keyboard handler. When the user reaches the trash icon, Tab continues to the next row.
  • Changes commit on input. Every keystroke fires onChange with the next array of rows. The owner debounces or batches as needed — the component is fully controlled.
  • Add row appends and stays. Clicking “Add new row” appends a fresh row from newRow(). Focus doesn't auto-jump into it — the user's eye still on the link clicked, the new row is one Tab away. Hold the affordance steady.
  • Delete is immediate. No confirm dialog — the row is removed instantly. Pair with undo at the page level (a transient toast with a restore action) for the safety net. Modal confirms on every delete are the worst kind of friction in bulk edit flows.
  • Mobile is one row at a time. Below md, each row is a card; the user fills it like a small form. The card stays the same shape whether the desktop layout was 4 columns or 14.

Accessibility

  • Real table semantics. Renders a native <table> with <th scope="col"> headers — screen readers announce “Sales price column, row 3” as the user navigates. The mobile card layout uses <dl> with label/value pairs for the same association.
  • Cell focus is highlighted. focus-withinon the cell paints the ring inset, so the user always knows which cell they're editing — even when the input itself doesn't visually change inside the cell.
  • Delete buttons announce row identity.Each trash icon's aria-label is “Delete row N” by default — override via deleteRowAriaLabel with something more specific (“Delete ‘Greek salad’”).
  • Required columns must be enforced upstream.The component doesn't validate — it's a controlled grid. Required-field validation lives in the parent: render error messages outside the table, or block save until the data is complete.

Code

tsx

import { EditableTable, type EditableColumn } from "@flatpay-dk/ui";

interface MenuRow {
  id: string;
  category: string;
  productName: string;
  salesPrice: string;
  vat: string;
  costPrice: string;
}

const COLUMNS: ReadonlyArray<EditableColumn<MenuRow>> = [
  { id: "category", header: "Category", type: "text", width: "16%" },
  { id: "productName", header: "Product name", type: "text", width: "22%" },
  { id: "salesPrice", header: "Sales price", type: "currency", width: "14%" },
  {
    id: "vat",
    header: "VAT",
    type: "select",
    options: [
      { value: "0", label: "0%" },
      { value: "9", label: "9%" },
      { value: "21", label: "21%" },
    ],
    width: "12%",
  },
  { id: "costPrice", header: "Cost price", type: "currency", width: "14%" },
];

let rowSeed = 0;
function makeRow(): MenuRow {
  rowSeed += 1;
  return {
    id: `r${rowSeed}`,
    category: "",
    productName: "",
    salesPrice: "",
    vat: "",
    costPrice: "",
  };
}

export function MenuEditor() {
  const [rows, setRows] = useState<MenuRow[]>([]);
  return (
    <EditableTable
      ariaLabel="Menu items"
      columns={COLUMNS}
      rows={rows}
      onChange={setRows}
      newRow={makeRow}
      emptyState="No items yet. Add a row below to start the menu."
    />
  );
}

Best practices

Sweep VAT across a season menu

Do

Use it for bulk edits — many rows, the same operation across most of them.

New prototype intake form

Don't

Don't use it for single-row creates. Reach for a TextField + Section form when the user is filling out one item end-to-end.

Delete → undo toast (5s)

Do

Pair delete with an undo toast. Bulk-edit flows live or die by the safety net for accidental deletions.

“Are you sure?” ×30

Don't

Don't add a confirm modal per row. Confirm dialogs in bulk flows are the worst kind of friction — they break the flow you're trying to enable.

Props

PropTypeDefaultDescription
columns*ReadonlyArray<EditableColumn<T>>Column schema in display order. Each column has an id, header, type, and optional options/currency/width/align/placeholder/readOnly.
rows*T[]Current rows. Must include a stable `id: string` per row for keying.
onChange*(rows: T[]) => voidCalled with the next array of rows on every cell change, add, or delete. The component is fully controlled.
newRow() => TFactory for a blank row. When provided, an "+ add new row" link appears below the table. Omit to forbid adding rows.
allowDeletebooleantrueWhen true, each row has a trash button at the end. Pass false for review-only surfaces.
ariaLabel*stringRequired for screen readers — describes what the table edits.
emptyStateReactNodeShown when rows.length === 0. Defaults to "No rows yet."
addRowLabelstring"Add new row"Label for the add-row affordance.
deleteRowAriaLabel(rowIndex: number) => stringOverride the default "Delete row N" aria-label with something more specific.
classNamestringPass-through class on the wrapper div.