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.
| Category | Product name | Sales price | VAT | Cost price | Barcode | SKU | |
|---|---|---|---|---|---|---|---|
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.
| Category | Product name | Sales price | VAT | Cost price | Barcode | SKU | |
|---|---|---|---|---|---|---|---|
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.
| SKU | Product | Sales price | VAT | Cost price |
|---|---|---|---|---|
| STR-GRK-01 | Greek salad | |||
| STR-BRU-01 | Bruschetta | |||
| MAI-MAR-01 | Margherita | |||
| MAI-CAR-01 | Carbonara | |||
| MAI-BAV-01 | Bavette steak |
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.
| Category | Product name | Sales price | VAT | Cost price | Barcode | SKU | |
|---|---|---|---|---|---|---|---|
| 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.
| Category | Product name | Sales price | VAT | Cost price | Barcode | SKU | |
|---|---|---|---|---|---|---|---|
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.
Container
Rounded-lg surface with a 1 px #EEEEEF border. Carries horizontal scroll on narrow desktops; mobile drops the container and renders cards instead.
Header row
Subtler grey background (
#F8F8F8), Inter Tight Medium 13/20 intext.tertiary. Sits at the top of the surface and stays visually separate from data rows.Cell
Each cell is a focused input. Five types — text / number / currency / select / readonly. Focus shows the system
#9EB7FFring inset to the cell, so the cell itself reads as the edit surface.Delete column
A trash icon per row, hover-darkens to the danger tone. Optional via allowDelete; hidden when reads only.
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
onChangewith 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-labelis “Delete row N” by default — override viadeleteRowAriaLabelwith 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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[]) => void | — | Called with the next array of rows on every cell change, add, or delete. The component is fully controlled. |
| newRow | () => T | — | Factory for a blank row. When provided, an "+ add new row" link appears below the table. Omit to forbid adding rows. |
| allowDelete | boolean | true | When true, each row has a trash button at the end. Pass false for review-only surfaces. |
| ariaLabel* | string | — | Required for screen readers — describes what the table edits. |
| emptyState | ReactNode | — | Shown when rows.length === 0. Defaults to "No rows yet." |
| addRowLabel | string | "Add new row" | Label for the add-row affordance. |
| deleteRowAriaLabel | (rowIndex: number) => string | — | Override the default "Delete row N" aria-label with something more specific. |
| className | string | — | Pass-through class on the wrapper div. |