Groups: blocks inside a block
On this page
A container block holds other blocks. The seller adds blocks inside it in the page editor, and
the container decides where they go: a column, a row, a grid, a panel with a background. A container
is a normal block with one extra field, f.blocks().
You can write your own, or install the registry's ready-made one: bizzpocket add group
(below).
Contents: A container in 20 lines · The layout JSON ·
f.blocks() options · What render gets ·
Data and streaming · Limits · The registry group block ·
What the editor and the hosts do
A container in 20 lines
Create blocks/panel/panel.tsx:
import { block, f } from '@bizzpocket/theme';
import './panel.css';
export default block({
name: 'Panel',
category: 'content',
description: 'A titled box with blocks inside, side by side or stacked.',
fields: {
title: f.text({ default: 'Our picks' }),
layout: f.select(['stacked', 'side-by-side'], { default: 'side-by-side' }),
colors: f.colorScheme(),
// The blocks inside. They are not a prop the seller types: the editor offers "add block inside".
items: f.blocks({ allow: ['hero', 'promo-banner', 'category:products'], max: 4 }),
},
render: ({ title, layout, items }) => (
<div className={`panel panel--${layout}`}>
<h2>{title}</h2>
<div className="panel__items">{items}</div>
</div>
),
});Create blocks/panel/panel.css:
[data-theme] .panel {
padding: 2rem;
background: var(--bp-color-background, transparent);
color: var(--bp-color-text, inherit);
}
[data-theme] .panel__items {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
[data-theme] .panel--side-by-side .panel__items {
flex-direction: row;
flex-wrap: wrap;
}
[data-theme] .panel--side-by-side .panel__items > * {
flex: 1 1 16rem;
min-width: 0;
}
/* The children must not inherit the panel's colour trait (see "What render gets"). */
[data-theme] .panel__items > * {
--bp-color-background: initial;
--bp-color-text: initial;
}items is an array of the children, already rendered. Put it where the children go and style the
direct children of that element (each child is its own <section data-block-type="...">).
The layout JSON
The children are saved in the layout, on the container's node, as children. They are layout nodes
like any other block: an id (a UUID), a type, props and visibility. A container node always
has children, even when it is empty ("children": []).
layouts/home.json, with the panel holding a hero and a promo banner:
{
"schemaVersion": 1,
"pageType": "home",
"pageIdentifier": "home",
"themeId": "my-theme",
"blocks": [
{
"id": "0b7c5d1e-3f4a-4b5c-8d6e-7f8091a2b3c4",
"type": "panel",
"props": { "title": "This week", "layout": "side-by-side" },
"visibility": { "devices": ["mobile", "desktop"] },
"children": [
{
"id": "1c8d6e2f-4a5b-4c6d-9e7f-8091a2b3c4d5",
"type": "hero",
"props": {},
"visibility": { "devices": ["mobile", "desktop"] }
},
{
"id": "2d9e7f3a-5b6c-4d7e-8f9a-91a2b3c4d5e6",
"type": "promo-banner",
"props": {},
"visibility": { "devices": ["mobile", "desktop"] }
}
]
}
]
}You do not have to write the ids by hand. add-to-layout adds a container with "children": [], and
--parent <id> adds a block inside it:
bizzpocket add-to-layout panel # prints: added panel to layouts/home.json (id 0b7c…)
bizzpocket add-to-layout hero --parent 0b7c5d1e-3f4a-4b5c-8d6e-7f8091a2b3c4--parent refuses an id that is not in the layout, or a block that has no f.blocks() field.
f.blocks() options
| Option | What it does | Default |
|---|---|---|
allow |
Which blocks may go inside. Each entry is a block type ('hero'), a whole category ('category:media'), or '*' for any block. A category: entry matches a block's category as the block sets it. |
['*'] (any block) |
max |
At most this many children. | 24 |
label |
The name of the "blocks inside" area in the editor. | from the key |
help |
Help text under it. | none |
allow and max are rules for the editor, and the renderer enforces them too: a saved child whose type
is not allowed is skipped (and logged), and children past max are not rendered.
The rules are written to dist/blocks.json. The block is marked "container": true, and its
propsSchema carries them, in place of a property:
"x-bp-children": { "childrenField": "items", "allow": ["hero", "promo-banner", "category:products"], "max": 4 }What render gets
- The
f.blocks()key is aReactNode[]: one element per child, in the saved order, each inside its normal block wrapper (<section data-block-type data-block-id>). An empty container gets[]. - Place the children; do not look inside them. You do not get their props or data. Wrap them, space them, put them in columns; that is all.
- The container's own fields work as usual, traits included. Trait variables inherit:
f.spacing()andf.colorScheme()set--bp-spacing-*/--bp-color-*on the container's wrapper, and a child whose CSS readsvar(--bp-spacing-top, 2rem)without a value of its own would get the container's. Reset them on the children withinitial(as the panel above and the registry group do); a child's own trait values are on its wrapper'sstyleattribute and still win. f.blocks()is not a prop the seller edits: it has no default, is not indefaultProps, and a saved value under its key is ignored.
The container's wrapper gets data-bp-container (for the renderer's streaming rules, below).
Data and streaming
Each child is a block of its own:
- Data. A child's
data()is resolved exactly like a top-level block's. The server walks the layout'schildrenwhen it collects a page's needs, so a page with groups is still one data call; a static child stays static. The container itself holds no data (it can havedata()like any block, but its children never see it). - Streaming. The container does not wait for its children. A child whose data is slow streams on its
own: its placeholder sits inside the container, and the rest of the container, and the blocks above,
paint at once. As for top-level blocks, nothing below a pending child is displayed until it fills, so
nothing jumps (
STREAM_ORDER_CSSandCONTAINER_ORDER_CSSin the runtime). - Islands and errors. A child's islands hydrate as usual. A child that throws renders as an empty
section; its siblings and the container are unaffected. A container whose own
renderthrows renders as an empty section, without its children. - LCP. When the first content block of a page is a container, its first children are the candidates for the page's priority (eager, preloaded) image.
Limits
| Limit | Value | What happens past it |
|---|---|---|
f.blocks() fields per block |
1 | block() throws (a build error naming both fields) |
Where f.blocks() may be |
top level of fields |
inside an f.list or f.group, block() throws |
| Nesting depth | a block may sit at most 3 levels below a top-level block (a group in a group in a group) | deeper children are not rendered; the renderer logs the block id |
| Children per container | max (default 24) |
the rest are not rendered (logged) |
The registry group block
bizzpocket add groupcopies blocks/group/group.tsx and group.css into your theme. It is a layout container with no data
and no JavaScript (0 KB in the size table's client gz column):
| Field | Kind | What it does |
|---|---|---|
layout |
select | column (stacked), row (side by side, wrapping), grid |
columnsMobile / columnsTablet / columnsDesktop |
number | grid columns below 48rem / from 48rem / from 64rem (1, 2, 3 by default) |
gap |
select | space between the blocks: none, s, m (your --spacing-block-gap), l |
align |
select | stretch, start, center, end across the layout direction |
width |
select | full, wide (90rem), content (72rem), narrow (44rem), centred |
padding |
f.spacing() |
padding from your spacing tokens |
colors |
f.colorScheme() |
background and text colour from your colour tokens |
blocks |
f.blocks({ max: 12 }) |
the blocks inside (any block) |
Its CSS is scoped [data-theme] .bp-group… and reads tokens with fallbacks, like every registry item. To
change what it accepts, edit the copy: blocks: f.blocks({ allow: ['category:content', 'category:media'], max: 6 }).
A layout node for it:
{
"id": "3e0f8a4b-6c7d-4e8f-9a0b-a2b3c4d5e6f7",
"type": "group",
"props": { "layout": "grid", "columnsDesktop": 4, "gap": "l" },
"visibility": { "devices": ["mobile", "desktop"] },
"children": []
}What the editor and the hosts do
- Bun runtime (
bizzpocket dev, and the runtime that serves themes): renders children as above. - Next host (
@bizzpocket/theme-sdk/v3): the adapter passes the React children the host gives a container's component into thef.blocks()key, andcontainerSpecOf(definition)tells the host which blocks are containers. The host has to render each child through its block pipeline and pass the results; until it does, a container renders empty there (host change list, item 25). - Seller editor: adding, reordering and removing blocks inside a container, honouring
allowandmaxfromblocks.json, is host change list item 24. Until then, put children in the default layouts by hand (or withadd-to-layout --parent).
Next: fields.md for every other field kind.