Layout Areas
An Area is a small layout container stored inside a zone - it holds widgets in up to four columns, it is not a widget itself, and it cannot hold another Area.
A zone is a list. Lists go downwards. Pages, unfortunately, also go sideways.
An Area is the smallest thing that fixes that: a container stored in the zone, holding a few widgets and one small layout description. It is what you reach for when two widgets belong next to each other, and nothing more than that.
zone
├─ block a full-width hero
├─ area 2 columns
│ ├─ block the explanation
│ └─ block the call to action
└─ block a closing noteThe three-way split
This is the idea the whole feature is built on, and it is worth stating plainly:
| Belongs to | Example | |
|---|---|---|
data | One block | "Ships on Friday", a heading, a link |
variant | One block instance | grid, list, compact |
Area.layout | The relationship between blocks | two columns, medium gap, top-aligned |
A block never describes the block next to it. That is why columns are not a field, and why Area exists at all.
The stored shape
An Area sits in the zone's array, beside the blocks, and is told apart by its kind:
[
{ "id": "01JC…HD", "type": "core:hero", "data": { "title": "Welcome" } },
{
"id": "01JC…KP",
"kind": "area",
"layout": { "columns": 2, "gap": "md", "align": "stretch" },
"children": [
{ "id": "01JC…R2", "type": "core:text", "data": { "heading": "Left" } },
{ "id": "01JC…T7", "type": "core:cta", "data": { "title": "Right" } }
]
}
]| Key | Rule |
|---|---|
kind | Always "area". It is the only thing that distinguishes an Area from a block, which is why a block may never declare it. |
id | The same kind of id a block instance has - stable, unique within the zone, and kept through every move and re-order. |
layout | The tokens below. columns is required; the rest have defaults. |
children | Blocks, in rendered order. Blocks only. |
Array order is rendered order, inside an Area exactly as outside one.
The layout tokens
Four closed sets. Not CSS, not class names - tokens VitNode renders, so a stored layout cannot turn into arbitrary styling smuggled through a content column.
| Token | Values | Default | What it does |
|---|---|---|---|
columns | 1 2 3 4 | 2 | How many columns on a wide screen. Always one column on a phone - see below. |
gap | none sm md lg | md | The space between children, in both directions. |
align | start center stretch | stretch | How children line up across a row. stretch gives equal-height columns. |
justify | start center stretch | stretch | How each child sits across its own column. stretch fills it. |
Areas are mobile-first: below the md breakpoint every Area is one column, whatever columns says. Two columns on a 360px screen is not a layout, it is a dare.
An Area is not a block
This is the part worth reading twice, because it is what keeps Areas cheap:
- There is no
core:areablock. Nothing registers it, nothing can be installed to provide it, andregistry.get("core:area")isundefined- on purpose. - It needs no entry in
allowedBlocks. A zone that allows["core:text"]still accepts an Area holdingcore:text. The allowlist governs blocks, and the children inside an Area are checked against the very same list as blocks at the zone's root. - It has no
data, no fields and no component of its own. There is nothing for a properties form to build, which is why the editor shows four selects instead.
In the editor's block catalogue it therefore lives in its own Layout section above the plugin groups, rather than pretending to be somebody's block.
<ContentZone
id="settings:sidebar"
allowedBlocks={['core:text']}
blocks={content.sidebar}
/>That zone accepts: core:text at the root, an Area, and core:text inside the Area. It still refuses core:cta in both places.
Areas cannot contain Areas
In Stage 4, an Area's children are blocks. Never another Area.
That is enforced everywhere it can be: the drag-and-drop resolver refuses the drop and says so out loud, the catalogue hides the Layout section while an Area is the insertion target, and the API refuses a nested Area on write. A stored document that somehow contains one is shown as unreadable rather than rendered.
Why one level, when the shape would obviously recurse?
Because every extra level costs more than it looks. Nesting needs depth limits, cycle detection, an answer for what an allowlist means three levels down, a keyboard drag model that can climb out of a container, and a properties panel that can say which area you are editing. One level covers "put these two things side by side", which is what people actually ask for, at a fraction of the surface area.
The shape is ready for it - children is a list of nodes, and a nested area would be one more node kind in it - so the door is closed, not locked.
In the editor
| Adding one | The Layout section of Available Widgets holds a single Area entry. It drops an empty Area into the targeted zone. |
| Filling it | Drag a block onto the Area, or use Add widget on the Area itself - the sidebar then says For: an area in that zone, and the Layout section disappears while it is targeted. |
| Selecting it | Click the Area's own shell (not a child) and the sidebar shows Area properties: Columns, Gap, the space above/below and left/right, Alignment and Distribution. Each control re-lays the page out as you pick it. |
| Ungroup | Removes the Area and leaves its children in the zone, at the position the Area held. Nothing is deleted. |
| Delete area | Removes the Area and its children. A non-empty Area asks first, and offers Ungroup, keep widgets in the same breath. |
An empty Area renders nothing at all in public - no wrapper, no gap, exactly like an empty zone. In edit mode it is a visible two-column drop target, because now somebody is looking for a place to put something.
Reading a zone that may hold Areas
A zone is a list of content nodes, and a node is a block or an Area. Two helpers keep that from spreading through your code:
import { contentNodeBlocks, isBlockAreaInstance } from '@vitnode/core/widgets'
isBlockAreaInstance(node) // narrows a node to an Area
contentNodeBlocks(nodes) // every block in the zone, areas flattened awaycontentNodeBlocks is what you want for anything that cares about blocks and not about arrangement: counting them, indexing their text, checking an allowlist. Rendering is already handled - ContentRenderer walks nodes and lays an Area out for you.
A migration you do not have to run
A zone stored before Areas existed is a list of blocks, and a list of blocks is a list of nodes that never nests. Nothing was rewritten, no column changed, and a page that never uses an Area keeps exactly the markup it had - the Area only exists in the DOM when one is stored.
When an Area is the wrong answer
| You want | Use |
|---|---|
| Two blocks side by side | An Area. This is the one. |
| One block that looks different | A variant. |
| A different arrangement per breakpoint | Neither, yet. One arrangement, rendered responsively. |
| A page-wide template - sidebar, footer, header | Several zones in your own page markup. An Area arranges content inside a zone; it is not a page layout. |
| Columns inside columns | Nothing. See above, twice. |
Widget Variants
A variant is how one widget instance looks, stored beside its data instead of inside it - declare a few with defineWidget and the editor grows a picker for free.
End-to-end Example
Define a block, register it with a plugin, add a blocks() field, write a record, and render it - the whole path in seven steps.