Widgets

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's

Keep 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:

plugins/example/src/widgets/features.tsx
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.' },
  ],
})
KeyRule
variantsA 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.
defaultVariantMust name one of them. Setting it without variants is refused too.
idLowercase 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.
labelWhat 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 data never 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" }
StoredDeclaredResult
nothingdefaultVariant: "grid"The component is given "grid".
nothingno defaultThe component is given undefined and decides for itself.
"list""list" is declaredThe component is given "list".
"carousel"not declaredUnknown. 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 variant key. It resolves to the block's defaultVariant, or to undefined if the block declares none - which is exactly how it rendered yesterday.
  • A block that adds variants later 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 wantUse
The same content in a different arrangementA variant.
Different content, or a field the other look does not haveTwo block types. A variant that needs its own fields is two blocks wearing one coat.
Blocks side by side in columnsAn Area. Arranging several blocks is never one block's job.
Something an editor should typeA 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.