Patterns / Canvas
Canvas
The canvas is the surface an assembled layout is read and edited on. It holds blocks of content, each on its own surface with the actions that belong to it, so a reader can take in a composed page and work on any part of it in place.
Like ConfirmationModal and ErrorView, this one the package ships. Canvas is a module: an arrangement you import rather than one described for you to rebuild, and it arrives in two parts — the board, and the block that sits on it.
What it fixes is the geometry. A block takes a whole row or half of one, there is no third fraction and no column count, and the room around and between them comes from named steps rather than a number you pass. What it leaves alone is the content: a block holds whatever you render inside it, and the board draws them in the order you wrote them.
The board
The board and its blocks are the same two surfaces a Card is built from. The board is the standalone card — --surface-depth-0 over --border-default at --radius-lg — and each block is a Tile on it: --surface-depth-1, --radius-md, and no visible border, because the board has already drawn the outer one. Hover a block and its border comes up to --border-default. That, rather than a shadow, is what says the block is live — a tile does not lift off the surface it is sitting on, and the action colour stays reserved for things you can click.
Pipeline by stage
A full row, because a stage breakdown is wide before it is tall.
Win rate
41%, up four points on the quarter.
Cycle time
18 days from first touch to signature.
Delete all three and the board says so instead of collapsing to nothing. The toolbar only exists because these blocks were given something to put in it: onDelete draws the trash button and actions takes anything else — here a Dropdown on the first block, at the same 36px as the button beside it.
Rows, and half a row
Four blocks: one full, then three halves. The first two halves pair into a row and the third takes half of the next one and leaves the rest empty. It does not stretch — the same markup would otherwise render two different widths depending on what happened to come after it.
Full
The whole row, whatever else is on the board.
Half
Paired with the one beside it.
Half
The other half of that row.
Half, alone
Half of the next row. The rest of it stays empty.
None of these has a toolbar, because none was given an onDelete or any actions. That is the block for content nobody acts on — readOnly is for a board someone is only reading, which is a different thing.
Padding and gap
The default, then padding="comfortable" with gap="regular", then padding="none". Watch the halves as the gap changes: a half block is 50% less half the gap, so widening the gap narrows both of them rather than pushing the second onto its own row.
Regular, compact
20px around, 8px between.
Regular, compact
The default board.
Comfortable, regular
24px around, 16px between.
Comfortable, regular
Room for blocks that each carry weight.
None
Flush to the board edge.
None
For a canvas already inside a padded panel.
Read-only
The same three blocks, each still passed an onDelete, on a board with readOnly. Every toolbar is gone rather than disabled, so there is no chrome implying an action that would not work, and the blocks stop answering the cursor. Set it on a single block to lock one of them inside a board that is otherwise editable.
Pipeline by stage
A full row, because a stage breakdown is wide before it is tall.
Win rate
41%, up four points on the quarter.
Cycle time
18 days from first touch to signature.
Filling a column
height="fill" inside a 320px flex column. The board takes what is left of the column and the blocks scroll inside it, so the board's own edges stay where they are — which is what a builder or a preview pane wants. Give it a parent that is not a flex column and it grows with its content instead, which is what auto already does.
Sessions
Enough blocks that the board has to scroll.
Signups
Enough blocks that the board has to scroll.
Activation
Enough blocks that the board has to scroll.
Retention
Enough blocks that the board has to scroll.
Expansion
Enough blocks that the board has to scroll.
Churn
Enough blocks that the board has to scroll.
There is no maxHeight. The board already scrolls, so a height belongs in your own rule on [data-slot="canvas"] — beside whatever else decides that layout, rather than passed control by control.
Nothing in it
An empty board says what is missing and draws no frame around it. A dashed border means a drop target and nothing here accepts one, so putting one there would be a promise the component cannot keep.
Add a section to start building this page.
When to use
Reach for the canvas when a screen is an assembly of blocks rather than one piece of content — a builder, a dashboard, a report someone edits in place. What it fixes is the geometry: a block takes a whole row or half of one, and the room around and between blocks comes from named steps rather than a number you pass, so every board in a product spaces itself the same way. What it leaves alone is the content, which is whatever you render inside a block. The habit to break is putting anything else straight on the board: a heading dropped there becomes a flex item sized to its own text rather than a row across the width, and it has none of the block’s surface under it. A heading for the board goes above the canvas; a heading for a block goes inside the block.
When not to use
A single surface carrying one reading and one action is a Card, and a Card is what a block holds rather than what replaces the board. The other way to miss is to reach for a module where the arrangement is already settled by the page: a two-column form, a filter row above a table. The tell is whether the blocks come and go — a set of surfaces known when you write the markup is a layout of your own, and the board would only be a heavier way of spelling it.
padding
How much room the board leaves around its blocks. Regular is 20px and is the answer unless something specific argues otherwise.
| padding | When to use |
|---|---|
none | Blocks flush to the board edge. For a canvas that is itself inside a padded panel, where a second inset would double the first. |
regular | 20px. The default, and the right answer most of the time. |
comfortable | 24px. For a board with few blocks and room to spare, where the extra air reads as deliberate rather than empty. |
gap
The space between blocks, in both directions. It also sets what half of a row is: a half block is 50% less half the gap, so the two axes cannot be set independently and a wider gap gives narrower halves rather than an overflowing row.
| gap | When to use |
|---|---|
compact | 8px. The default. Blocks read as parts of one board, which is what you want when there are more than a few. |
regular | 16px. For a handful of blocks that are each substantial — a chart apiece — where the tighter gap makes them look crowded. |
height
Whether the board sizes to its blocks or to the space it is given. Fill is what a working surface wants — the blocks scroll inside a board that stays put — and it needs a parent that is a flex column, because it claims the column’s remaining height rather than measuring anything.
| height | When to use |
|---|---|
auto | The board grows with its blocks and the page scrolls. The default, and correct wherever the canvas is one section of a longer page. |
fill | The board takes the rest of its flex column and scrolls internally. For a canvas that is the screen: a builder, an editor, a preview pane. |
size
A block claims a whole row or half of one. There is no third fraction and no column count — the row is the unit, so blocks wrap without any of them knowing where they sit.
| size | When to use |
|---|---|
full | The whole row. The default, and what anything wide belongs in: a table, a chart with a legend, a paragraph of prose. |
half | Half the row, so two sit side by side. One on its own leaves the other half of the row empty rather than stretching, which is the honest rendering — pair them, or make it full. |
A block with something to act on
The strip across the top of a block appears only because the block was given something to put in it. onDelete draws the trash button; actions takes whatever goes before it — a Dropdown, a ghost Button or two — at the same height, so nothing in the row sets the toolbar’s height on its own. A block given neither renders no strip and no divider over blank space.
A board someone is only reading
Read-only is the difference between a layout someone is building and one they are looking at. Every block drops its toolbar rather than disabling it, so there is no chrome implying an action that would not work, and the blocks stop answering the cursor — the border no longer comes up under the pointer, because a locked block is not a thing to reach for.
One block locked on an editable board
A block’s own readOnly wins over the board’s, in both directions: set it on one block of an editable canvas to lock that one, or set it false on a block of a read-only canvas to hand that one its toolbar back. Leave it off and the block takes whatever the board says.
A board with nothing on it
With no blocks the board says so rather than collapsing to nothing, and draws no frame around the label: a dashed border means a drop target and nothing here accepts one, so putting one there would be a promise the module cannot keep. A conditional child that renders false does not count as a block, so the label still appears when the last one goes.
The two parts are the two surfaces a Card is built from. The board is the standalone card — --surface-depth-0 over --border-default at --radius-lg — and each block is a tile on it: --surface-depth-1, --radius-md, and no visible border, because the board has already drawn the outer one. Hover a block and its border comes up to --border-default. That, rather than a shadow, is what says the block is live: a tile does not lift off the surface it is sitting on, and the action colour stays reserved for things you can click.
Canvas renders a div and adds no landmark, no heading and no list semantics, because a board of blocks is a layout and an outline invented for it is a structure the page never asked for. Where the board is a region in its own right, put it in a section of your own with a heading above it, and give a block its heading inside the block with the content. The empty label is an ordinary paragraph rather than a live region, so a board that empties while the reader is on it announces nothing — say so yourself where that matters.
Nothing on the board is responsive. A half block is half the row at every width and no rule collapses it, and a lone half leaves the rest of its row empty rather than stretching — the same markup rendering two different widths depending on what followed it would be the worse answer. A board that has to work narrow is a board you give full blocks, not one to add a breakpoint to.
Canvas has no drag handle and will not reorder. A screen that needs its blocks rearranged changes the list it renders; the board holds no order to ask for.
Give fill a flex column to fill. A parent that is not display: flex; flex-direction: column with min-height: 0 leaves the board growing to its content, which is what auto already does.
Cap a scrolling board from outside. There is no maxHeight — the root already scrolls, so a height belongs in your own rule on [data-slot="canvas"], next to whatever else decides that layout.
A block with neither actions nor onDelete renders no toolbar at all, which is the right block for content nobody acts on. Do not reach for readOnly to get there: read-only says the board is being read rather than built, and that is a statement about the whole board.
Block content is padded. For a table or a chart that should meet the block’s border, unpad it in your own stylesheet through [data-slot="canvas-block-content"].
Put a confirmation in front of onDelete when the block cannot be got back. Deleting is one click and the toolbar is always under the cursor — ConfirmationModal is the module for it.
The board holds nothing
Everything on the board is a rendering of your own state. It draws blocks in the order you wrote them and keeps no order of its own, and it deletes nothing — onDelete is a callback, and the block is gone when your list no longer has it. That is why the handler takes no argument: it is written where the block is, so it already knows which one it is.
All of them the module's own. The other two modules wrap a component and narrow its props — a confirmation is a Modal that cannot be resized, a failure is an EmptyState that cannot accept a drop — whereas the board and the block are their own elements, so there is nothing here inherited and nothing withheld. The rows marked CanvasBlock belong to the block; the rest are the board's.
| Owner | Prop | Type | Default | Notes |
|---|---|---|---|---|
Canvas | padding | "none" | "regular" | "comfortable" | "regular" | 0, 20px or 24px around the blocks. |
Canvas | gap | "compact" | "regular" | "compact" | 8px or 16px between blocks, and what a half block is half of. |
Canvas | height | "auto" | "fill" | "auto" | Grow with the blocks, or fill the flex column and scroll inside it. |
Canvas | readOnly | boolean | false | Drops every block’s toolbar. A block that sets its own wins over it either way. |
Canvas | emptyLabel | string | "No blocks yet." | Shown while the board has no blocks. There is no border around it: a dashed frame means a drop target, and nothing here accepts one. |
Canvas | children | ReactNode | — | The blocks, drawn in the order you write them. Anything else put here is a flex item sized to its own content rather than a row across the board. |
Canvas | CanvasBlock | part | — | What goes on the board, and the only thing that should. It carries data-slot="canvas-block", and takes the board’s readOnly unless it sets its own. |
Canvas | canvas | [data-slot] | — | The board itself. This is the handle for a height cap, and where a rule of your own places the board inside a layout. |
CanvasBlock | size | "full" | "half" | "full" | A whole row, or half of one. |
CanvasBlock | actions | ReactNode | — | Controls for the toolbar, before the delete button — a Dropdown, or a ghost Button or two. |
CanvasBlock | onDelete | () => void | — | Shows the delete button. It takes no argument: the handler is written where the block is, so it already knows which one it is. |
CanvasBlock | deleteLabel | string | "Delete block" | Accessible name for the delete button, which carries a glyph and no text. |
CanvasBlock | readOnly | boolean | the board’s | Locks this block whatever the board says — including false inside a read-only board, which hands this one its toolbar back. |
CanvasBlock | children | ReactNode | — | The block’s content: a chart, a table, a paragraph. The module lays it out no further than the padded wrapper it goes in. |
CanvasBlock | canvas-block-content | [data-slot] | — | That wrapper. Unpad it here for content that should meet the block’s border. |