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.
Sooner or later somebody asks for "the same callout, but smaller". You have three options: a second widget type, a size field in the middle of the copy, or a variant.
Variants are the third one, and they exist because the first two both lie about what changed. The words did not change. The shape did.
data what the widget says "Ships on Friday"
variant how this instance says it "grid" | "list" | "compact"
layout how widgets sit together an Area's job, not a widget'sKeep those three apart and every screen that touches a widget stays honest. Mix them and a widget definition slowly turns into a settings page.
Declaring them
Two optional keys on defineWidget:
export const featuresWidget = defineWidget({
component: Features,
defaultVariant: 'grid',
fields: featuresFields,
id: 'features',
name: 'Features',
variants: [
{ id: 'grid', label: 'Grid', description: 'Cards side by side.' },
{ id: 'list', label: 'List', description: 'One per row.' },
{ id: 'compact', label: 'Compact', description: 'A dense summary.' },
],
})| Key | Rule |
|---|---|
variants | A list of { id, label?, description? }. Leave it off entirely if the widget has one look - variants: [] is refused, because a picker of nothing is worse than no picker. |
defaultVariant | Must name one of them. Setting it without variants is refused too. |
id | Lowercase letters, digits and single hyphens, up to 32 characters. It is written into every instance that picks it, so it has to survive the round trip unchanged. |
label | What the editor shows. Falls back to the id, so a one-word variant needs no label at all. |
Two variants sharing an id is refused: a stored instance names its variant by id alone, so the two could never be told apart.
Reading it in the component
The variant arrives as a prop, next to the data rather than inside it:
const Features = ({ data, variant }: WidgetComponentProps<FeaturesData>) => (
<section className={variant === 'compact' ? 'gap-2' : 'gap-4'}>
<h2>{data.heading}</h2>
{/* same data, three arrangements */}
</section>
)variant is string | undefined. Undefined means "nobody picked one", which is exactly when defaultVariant applies - so a component that wants a single answer can start with const shape = variant ?? "grid".
The stored shape
A variant is one more key on the instance, beside data and never in it:
{
"id": "01JC4Z8N9WQKX7R2M5T6V3B8HD",
"type": "example:features",
"variant": "list",
"data": {
"heading": "One block, three shapes",
"primary": { "title": "Data" }
}
}That placement is the whole feature. Because variant is a sibling of data:
- switching it never touches, re-validates or re-saves the content,
- the block's schema stays the schema of its content, so a search indexer reading
datanever trips over a styling token, - and a block that drops a variant later does not need a data migration to get rid of it.
How a variant resolves
import { resolveWidgetVariant } from '@vitnode/core/widgets'
resolveWidgetVariant(featuresWidget, undefined) // { kind: "resolved", variant: "grid" }
resolveWidgetVariant(featuresWidget, 'list') // { kind: "resolved", variant: "list" }
resolveWidgetVariant(featuresWidget, 'carousel') // { kind: "unknown", variant: "carousel" }| Stored | Declared | Result |
|---|---|---|
| nothing | defaultVariant: "grid" | The component is given "grid". |
| nothing | no default | The component is given undefined and decides for itself. |
"list" | "list" is declared | The component is given "list". |
"carousel" | not declared | Unknown. The block is not rendered with a guess. |
An unknown variant is treated the way unreadable data is: the page shows a fallback rather than inventing a look, and the properties panel says which variant is stored and offers the ones the block does declare, so somebody can fix it in one click. Nothing is deleted on your behalf.
Why not just fall back to the default?
Because "carousel" meant something to whoever chose it. Silently rendering a grid would ship a page that looks fine to everybody except the person who asked for a carousel, and nothing anywhere would say it had been overruled. A visible fallback is a worse-looking page for an hour; a silent one is a wrong page forever.
In the editor
Select a block that declares more than one variant and a Variant picker sits above its fields in the properties panel.
Picking one is a single action - it updates the page immediately, marks the zone unsaved, keeps the instance's id, and leaves data byte for byte as it was. It is not an edit of the block's content, and the fields do not reset while you try the options.
A block with one variant, or none, shows no picker at all. There is nothing to decide.
Backward compatibility
Nothing was migrated when variants landed, and nothing needs to be.
- An instance stored before variants existed has no
variantkey. It resolves to the block'sdefaultVariant, or toundefinedif the block declares none - which is exactly how it rendered yesterday. - A block that adds
variantslater keeps rendering every instance already stored. They simply all take the default until somebody picks something else. - A block that removes a variant does not break its zone: the instances holding it are flagged as unknown and fixable, not dropped.
variant is optional in the stored shape, in the API's validation and in the type. A zone full of { id, type, data } is a perfectly valid zone.
When a variant is the wrong answer
| You want | Use |
|---|---|
| The same content in a different arrangement | A variant. |
| Different content, or a field the other look does not have | Two block types. A variant that needs its own fields is two blocks wearing one coat. |
| Blocks side by side in columns | An Area. Arranging several blocks is never one block's job. |
| Something an editor should type | A field. If the answer is prose, a URL or a number, it is content. |
The smell test: if changing the option would make you want to re-word the copy, it is data. If the copy reads the same either way, it is a variant.
Editable Pages
Declare a page's zones once, and the people who run the site rearrange them on the page itself - Edit widgets from the user menu, drag, Save, done. No admin screen, no zone-to-column mapping, no hand-written save route.
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.