Component Metadata Guide

This guide explains how to write meta.js files for Uniweb components.

Overview

A meta.js file declares a section type's content interface:

  • What the component does (for documentation and editor UI)
  • What content it expects from markdown
  • What parameters content authors can configure
  • What presets are available for quick setup
  • What data it receives — the content.data keys it declares

In src/sections/, meta.js is optional at the root level. A bare file (Hero.jsx) or folder (Hero/Hero.jsx) is automatically an addressable section type with an implicit empty content interface. Add meta.js when you need params, content expectations, or presets. Deeper nesting within src/sections/ requires meta.js for a component to be addressable.

Components without meta.js (outside of src/sections/ root) are ordinary React components — not selectable by content authors, but the foundation's actual rendering workhorses.


Minimal Examples

The simplest section type — no meta.js at all:

src/sections/TextSection.jsx    ← addressable, title inferred as "Text Section"

When you need params or content expectations, add a meta.js:

export default {
  title: 'Text Section',    // Optional — inferred from component name if omitted
}

What's Required vs Optional

When meta.js is present:

FieldRequiredDefault
titleNoInferred from component name (TeamRoster → "Team Roster")
descriptionNo—
familyNo— (the editor falls back to a generic illustration)
hiddenNofalse
backgroundNofalse
stickyNofalse
insetNofalse
childrenNo—
dataNo— (the section receives no data of its own)
contentNo—
paramsNo—
presetsNo—
varsNo—
contextNo—
initialStateNo—

When meta.js is absent (only at root of src/sections/), the section type has no params, no content expectations, and a title inferred from the file or folder name.


Full Example

// sections/Hero/meta.js
export default {
  title: 'Hero Banner',
  description: 'Bold hero section with headline and call-to-action',
  background: true,

  data: {
    events: '@std/event',  // the section receives content.data.events, shaped by @std/event
  },

  content: {
    title: 'Headline',
    pretitle: 'Eyebrow',
    paragraphs: 'Description [1-2]',
    links: 'CTA buttons [1-2]',
    image: 'Hero image [1]',
  },

  params: {
    variant: {
      type: 'select',
      label: 'Variant',
      options: ['gradient', 'glass', 'minimal'],
      default: 'gradient',
    },
    layout: {
      type: 'select',
      label: 'Layout',
      options: [
        'center',
        'left',
        { value: 'split-right', label: 'Split (image right)' },
        { value: 'split-left', label: 'Split (image left)' },
      ],
      default: 'center',
    },
  },

  vars: {
    'content-gap': {
      default: '2rem',
      label: 'Content Gap',
      type: 'select',
      options: ['1rem', '1.5rem', '2rem', '3rem'],
      group: 'Layout',
    },
  },

  presets: {
    default: {
      label: 'Centered Hero',
      params: { variant: 'gradient', layout: 'center' },
    },
    split: {
      label: 'Split Layout',
      params: { variant: 'gradient', layout: 'split-right' },
    },
  },

  // Static — neighbors read via getNextBlockInfo().context
  context: {
    allowTranslucentTop: true,
  },

  // Dynamic — neighbors read via getNextBlockInfo().state
  // Component can update with useBlockState()
  initialState: {
    allowTranslucentTop: true,
  },
}

How Data Flows to Your Component

The meta.js describes the contract. Here's what your component actually receives:

function Hero({ content, params, block, website }) {
  // ─── From markdown (content) ────────────────────────
  // Runtime guarantees all fields exist - no defensive checks needed
  const { title, pretitle, subtitle, paragraphs, links, images, items } = content

  // ─── From frontmatter (params) ──────────────────────
  const { variant, layout } = params

  // ─── From a declared data key (a query's records) ──────
  const events = content.data?.events || []

  // ─── From structured author data (tagged blocks or form UI) ──
  const navLinks = content.data?.['nav-links'] || []

  return (
    <section className={variant}>
      {pretitle && <span className="eyebrow">{pretitle}</span>}
      {title && <H1>{title}</H1>}
      {paragraphs.map((p, i) => <P key={i}>{p}</P>)}
    </section>
  )
}

Runtime guarantees: The runtime ensures all content fields exist with sensible defaults (empty strings/arrays). You don't need defensive null checks—content structure is guaranteed.

SourceDeclared inAccessed via
Markdown contentcontent: { ... }content.title, content.paragraphs, content.items
Frontmatter paramsparams: { ... }params.paramName
A query's recordsdata: { events: '@std/event' }content.data.events
Structured author datadata: { 'nav-links': { ... } }content.data['nav-links']

Note: block.data and content.data point at the same object at runtime. Use content.data.* in components — it matches how the rest of the framework surfaces the content tree.


Schema Reference

Identity Fields

FieldTypeDescription
titlestringDisplay name in the editor. If omitted, inferred from the component name (TeamRoster → "Team Roster")
descriptionstringWhat the component does
familystringThe standard section family this component belongs to. Picks the editor's illustration, its icon and a label it can translate. Optional — see below
hiddenbooleanIf true, component is excluded from export entirely (internal helpers, not-yet-ready components)
insetbooleanIf true, available for @ComponentName references in markdown

Family

An editor that lets an author add a section has to draw something for each one. family is how it knows what to draw — an illustration and an icon — and which translated label to show. The icon is the family's: there is no icon to declare.

Most components need no declaration. The section type is the component name, so a component called Hero, Footer or Pricing already says what it is and resolves on its own. family is for the ones whose name does not give it away:

// sections/ProfileHero/meta.js
export default {
  title: 'Researcher Profile',
  family: 'profile',
}

ProfileHero, CvEntry and PublicationsByYear are all good names that no standard list will ever contain. One line says which family they belong to.

A name of your own is fine. An unrecognized value — or none at all — falls back to a generic illustration. Nothing breaks, nothing is gated, and you are never waiting for a name to be added before you can ship. Plenty of sections are a thing your site has and nobody else's, and those should stay undeclared.

One value, the dominant shape. A HeroWithEstimate is in the hero family; the picker draws one picture, so name the shape that dominates.

family is a claim about shape and nothing else. No part of the build, the runtime or the delivered site reads it — a foundation that declares none renders identically.

Run uniweb families to see the list, or uniweb doctor to see what each of your sections resolved to and what it would suggest for the rest.

GroupFamilies
Opening — say what this page ishero · profile · statement · announcement · countdown · marquee · canvas
Explaining — your own words and picturesarticle · rich-text · story · editorial · figure · gallery · video · code-block · callout · steps · process · roadmap · faq
Listing — show many of somethingcard-grid · team · products · jobs · schedule · menu · social-feed · changelog · downloads · bibliography · teaser · recommendations
Convincing — evidence and prooffeatures · testimonials · evidence · logo-cloud · integrations · spotlight · hotspots · pricing · stats · metrics · data-table · comparison
Acting — get the visitor to do somethingcta · contact · newsletter · booking · wizard · quiz · calculator · estimate · search · auth · paywall · app
Navigating — help them get aroundheader · footer · pathways · toc
Embedding — bring in something externalmap · embed · raw-code · widget
Organizing — arrange other content on the pagegrid · tabs · accordion

Whatever you pick describes the kind of section — not the kind of site it suits. A genre like marketing or docs belongs to a template, not to a section type.


Content

The content object says what your component expects from its section's markdown — which elements it reads, how many, and what an author should write there.

content: {
  title: 'Headline [1]',                 // a label, with a count
  paragraphs: 'Description [1-2]',
  links: 'CTA buttons [0-2]',
  media: 'Photo, video or diagram [1]',  // an image, a video, or an embedded component

  // The object form: a hint, and for `items`, what each entry holds
  items: {
    label: 'Feature cards [3-6]',
    hint: 'Each ### heading starts a card',
    content: { title: 'Feature', paragraphs: 'Description', icon: 'Icon [1]' },
  },
}

A value is one of:

FormExampleMeans
A label'Headline'The element, named for the author
A label with a count'Cards [3-6]'…and how many — see Count Syntax
truetitle: trueThe element, with no label of yours — the editor uses its own words
An object{ label, hint }A label and a longer hint. media also takes types, sequence takes except and placement, and items takes content

A component that takes no content says so with content: {} (or content: []) — one that renders only what it receives in content.data, say. The visual editor then offers no text editor for its sections. Leaving content out says nothing about what the component reads, which is different.

The label is for authors. A visual editor shows it to the person writing the section, so write what they need to know — "Product photo", "Rich content" — not a variable name. It is shown as you wrote it, in every language, so write it in your authors' language.

Count Syntax

Append count in brackets at the end of the label:

SyntaxMeaning
'Label'Any count (default)
'Label [1]'Exactly 1
'Label [1-3]'1 to 3
'Label [2+]'2 or more

Nothing rejects content that ignores a count — a section renders with whatever arrived. But a count is not inert: it is what the visual editor reads to decide how much starter content to put in a new section, and what it shows an author as the section's expectations. Writing [3-6] on a card grid is therefore worth doing.

⭐ The editor generates for the upper bound. A component need not render everything it is handed, and a param that reduces (layout: compact) simply renders less — so surplus content costs nothing, while a shortfall leaves a visible hole. [0-2] produces two, and a floor of 0 still produces one: the floor is the author's option to delete, not an instruction to start empty.

Standard Content Elements

These names are a fixed vocabulary — each is something the semantic parser extracts from markdown, and the component reads it as content.<name>:

ElementSourceDescription
titleH1Main headline
pretitleA #> label lineEyebrow/kicker text
subtitleHeading after titleSecondary headline
paragraphsBody textDescription paragraphs
links[text](url)Markdown links (become buttons/links)
lists- itemBullet or numbered lists
itemsSubsequent headingsContent groups within the markdown — see What each entry holds
media![…](…) on its own lineAn image, a video, or an embedded component — see Media
icons![](lu-star)Icons an author picks
documents![…](file.pdf){role=pdf}Files, with an optional preview
snippets```jsCode samples
tablesA markdown tableTables
math$ … $Displayed equations
quotes> …Block quotes
sequenceThe whole sectionContent rendered as written — see Sequence

Use these exact names. The meta.js describes which of these your component uses — you're not inventing new names, you're declaring which parsed elements you consume. Child sections from separate files are not content elements: they arrive as block.childBlocks, and a section type that accepts them declares children. A tagged block the author writes is declared in data — a concept block as 'md:<tag>'.

Media

media is the section's visual media: an image, a video, or an embedded component, each on its own line. Declared without types it takes any of the three, and narrowing is the extra work:

content: {
  media: 'Hero media [1]',                                    // any of the three
  // media: { label: 'Product photo [1]', types: ['image'] },  // images only
}

types takes 'image', 'video' and 'inset' (an embedded component). The familiar spellings are the same declaration, narrowed to one type:

SpellingIs
image, images, thumbnailmedia with types: ['image']
videosmedia with types: ['video']
insetsmedia with types: ['inset']

A component reads the slot as content.media — its items in the order the author wrote them, each with its kind ('image', 'video' or 'inset') — or by kind, as content.images, content.videos and content.insets. Kit's <Visual> renders an item by its kind; handed the whole list it renders the first, because a slot of one is the first thing the author placed in it:

<Visual media={content.media} block={block} className="rounded-lg" />

⚠️ The one mistake the name invites: rendering the slot with an image or video component and dropping a component the author placed there. Kit's <Media> is a video player — for the slot, use <Visual>.

  • An icon is not media — it is a glyph an author picks. Declare icons (or icon).
  • A document is not media — declare documents. A {role=pdf} file arrives in content.documents, never among the images.
  • An image inside a sentence belongs to the text. It arrives in content.images and in content.sequence, but not in content.media.
  • Declare a thing once. media beside image would count one image toward both; the build says so.

visuals is retired. The slot is the media element: visuals: 1 is media: '… [0-1]', and visuals: { types: ['image'], count: 'many' } is media: { label: '…', types: ['image'] }. The build refuses a top-level visuals and names the equivalent.

Sequence — content rendered as written

A component that renders its content in the order the author wrote it — prose with images, tables, equations and embedded components in between — declares sequence:

content: {
  title: 'Headline [1]',
  sequence: 'Prose and media',
  // leaving out kinds it does not render:
  // sequence: { label: 'Rich content', except: ['table', 'math'] },
}
  • It covers the whole section, in order — the headline included. The component renders content.sequence, as kit's <Render>, <Prose> and <Article> do. title beside it says the section should begin with one; it does not mean the title renders apart from the rest.
  • except lists the kinds of content it does not render — heading, prose, list, link, image, video, inset, icon, document, code, table, math, quote. Every other kind reaches the page, including one the framework adds later.
  • A count on sequence means nothing, so it takes none.

placement says where an author may place a block — an image, a video or a table — in the flow your component renders (Placement):

content: {
  sequence: { label: 'The article', placement: true },
}
WrittenMeans
placement: trueleft, right, wide and full, on every kind that takes one
placement: ['wide', 'full']those, on every kind
placement: { image: ['left', 'right'], table: ['wide'] }those, per kind — image, video, table
left outnone

A visual editor offers an author only what you declare, so declare what your component lays out: kit's <Render>, <Prose> and <Article> honour all four, so a component built on them declares true. A kind except leaves out takes none. The build writes the declaration as a map from each kind to its values, and says when a section's file asks for a placement its type does not offer. It is for authoring only, and never reaches a published page, which renders whatever placement its content holds. A record's body is not covered: a record is shown by whatever section a site gives it.

A content: declared without sequence says only what it names, and a component with no content: says nothing about its content at all.

What each entry holds

items takes content: — the same map a section's is, for one entry:

content: {
  title: 'Headline',
  items: {
    label: 'Team members [2+]',
    content: { title: 'Name', subtitle: 'Role', image: 'Portrait [1]', paragraphs: 'Bio' },
  },
}

Each entry is built the way a section is, less the grouping, so an entry's content: holds neither items nor a sequence. Each entry in content.items has its own media, images and the rest.

What the build does with it

The build checks the declaration and lowers it into one form in the foundation's schema — what a visual editor reads: every media spelling becomes media with its types, a count becomes a minimum and a maximum, and a label appears only where you wrote one. Anything it cannot read — an unknown name, a count on sequence, two declarations of one thing — is a build warning, and the rest is still used. @uniweb/schemas/content's describeContent reads a meta.js the same way.

Background Media

Background images and videos are handled at the engine level so components don't repeat this logic. Use the top-level background field:

export default {
  title: 'Hero',
  background: true,  // Engine renders background media

  content: {
    title: 'Headline',
    image: 'Hero image [1]',
  },
}
ValueBehavior
'self'The component draws its own background; the runtime draws none behind the section. To draw the author's background itself, the component renders kit's <SectionBackground block={block} />
anything else, or left outThe runtime draws the author's background behind the component

true and false change nothing at render; an editor may read them to decide whether to offer a background setting. ⛔ This table listed 'manual' for a component that draws its own background until 2026-09-28 — the runtime has only ever read 'self'.

Frontmatter Background Options

When a component has background: true, authors can configure backgrounds in section frontmatter:

---
type: Hero
background:
  # Image background
  image: /images/hero.jpg
  position: center          # CSS background-position
  size: cover               # CSS background-size

  # Video background (falls back to image on mobile or reduced-motion)
  video: /videos/hero.mp4   # Auto-detects webm fallback if available
  poster: /images/hero.jpg  # Shown while video loads

  # Or provide explicit video sources
  sources:
    - src: /videos/hero.webm
      type: video/webm
    - src: /videos/hero.mp4
      type: video/mp4

  # Overlay on top of background
  overlay:
    enabled: true
    type: dark              # 'dark' or 'light'
    opacity: 0.5
    # Or gradient overlay
    gradient:
      start: 'rgba(0,0,0,0.7)'
      end: 'rgba(0,0,0,0)'
      angle: 180
---

Accessibility: Video backgrounds automatically respect prefers-reduced-motion. When users have reduced motion enabled, the poster image is shown instead of video.

Format Fallback: When you provide a .mp4 video, the engine automatically tries a .webm version first (better compression). Just ensure both formats exist at the same path.


Data

The data field declares the content.data keys your component receives, each with its schema. It is the single declaration surface for a component's structured data — there is no separate schemas: key.

A section's content.data holds the keys its component declares, and nothing else (plus any its foundation declares in main.js). A component that reads content.data.events declares events. Each key is filled from the fetches and tagged blocks that reach the section — by name, or, for a fetch under another name, by the query's schema — and a key nothing fills is null. The schema tells the editor what shape to expect and uniweb validate what to check; it changes no record — a field a record lacks reaches the component absent, and what it renders as is the component's choice. See Data Fetching → What a section receives.

data: {
  events: '@std/event',                            // named ref (shared standard)
  specs:  { cpu: { type: 'string' } },                // inline field map
  signup: { fields: [{ id: 'email', type: 'text' }] },// inline rich-form (editor form)
}

Each entry's key is the content.data key. Its value is one of three forms (below), or {} for a key whose records have no schema — an external API's. A key written 'md:<tag>' declares a concept block the author writes, read as content.data.<tag>. The site, author, or editor decides how each key is filled — a query's records, a tagged code block, or an editor form — and the schema is the same regardless of source.

A component with no data field, or data: false, receives no keys of its own.

Changed: delivery used to be default-on, and data: a hint for the editor and defaults. It is now what the section receives. The frontmatter form fetch: { query: articles, current: exclude, limit: 3 } in a .md file is a different mechanism — what a section fetches, and on a parametric page how it uses the page's record. See Data Fetching for details.

The three value forms

1. Named ref — points at a schema module, resolved at build time. Refs use Uniweb namespacing:

data: {
  members: '@/member',       // '@/' = this foundation's own foundation/schemas/member.{js,json,yml}
  authors: '@std/person',    // '@std/' = a shared standard schema
}

@/name is the self namespace — a schema this foundation owns, in foundation/schemas/. The empty scope means "this foundation," so a foundation never writes its own org name in source; @/-refs are portable. @std/name names the shared standards namespace (shipped in the @uniweb/schemas package); @org/name resolves from that org's @org/schemas package, letting a team share schemas across foundations. A ref names a namespace, not a package path.

A foundation-local schema file (.js, .json, .yml, or .yaml) holds the canonical shape:

# foundation/schemas/member.yml   — referenced as '@/member'
name: member
version: 1.0.0
description: A team member
fields:
  name:   { type: string, required: true }
  role:   { type: string, default: '' }
  bio:    { type: string, default: '' }
  avatar: { type: string, default: '' }

The name/version are the schema's identity (recorded in the foundation's published metadata) — they are not content.data keys.

2. Inline field map — a flat keyed object, written directly in meta.js. Good for one-off foundation-local shapes:

data: {
  pricing: {
    name:        { type: 'string', required: true },
    price:       { type: 'number', required: true },
    period:      { type: 'string', default: 'month' },
    features:    { type: 'array', items: { type: 'string' } },
    highlighted: { type: 'boolean', default: false },
  },
}

Nested shapes. An array declares its element type with items; an object declares its known keys with fields:

data: {
  page: {
    seo:   { type: 'object', fields: { title: { type: 'string' } } },
    tags:  { type: 'array',  items:  { type: 'string' } },
  },
}

When the keys are the author's rather than yours — a map of arbitrary names — declare values instead of fields. It is to an object what items is to an array:

// A map keyed by whatever the author calls each entry; every value the same shape.
rates: {
  type: 'object',
  values: { type: 'object', fields: { label: { type: 'string' }, amount: { type: 'number' } } },
}

Validation then checks every entry and names the offending key (rates.overnight.amount). Keys the value shape doesn't declare are left alone rather than rejected, so data can carry more than you described without failing a build. Declare fields or values, not both.

3. Inline rich-form — an editor form, distinguished by a fields array. See Structured Author Data below for the full rich-form reference.

Standard schema refs

The shared @uniweb/schemas package provides a common vocabulary any foundation can reference:

RefDescription
@std/personTeam members, authors, people
@std/articleBlog posts, news items
@std/eventCalendar events
@std/projectPortfolio/case studies
@std/publicationAcademic papers, research
@std/opportunityJobs, grants, calls
@std/serviceServices a business offers
@std/productThings a business sells
@std/testimonialWhat a customer, client or patient said
@std/positionA role someone held: a job, an appointment or a volunteer post
@std/qualificationA degree, diploma or certificate someone earned

Add @uniweb/schemas as a dependency when a @std/<name> ref is used. Foundations that only use @/-refs or inline schemas don't need it.

Routing a scope with schemas.config.js

By default an @org/name ref resolves to a package — the @org/schemas package in the foundation's node_modules. A foundation can instead route a scope to a directory: a plain folder of schema files anywhere on disk, with no package and no install. Add an optional schemas.config.js at the foundation root:

// foundation/schemas.config.js
export default {
  '@acme':  '../shared/acme-schemas',   // relative to the foundation
  '@brand': process.env.BRAND_SCHEMAS,   // absolute / machine-specific, via env
}

@acme/person then resolves to ../shared/acme-schemas/person.{js,json,yml,yaml} — a bare schema file, not a package. The config is plain JS (like main.js and vite.config.js), so a path can be relative to the foundation, absolute, read from an environment variable, or computed — whatever the layout needs.

This is how a team reuses one set of schemas across many foundations, or across many workspaces, without publishing a package: keep the schemas in one folder and point each foundation's schemas.config.js at it.

A key can also name a single schema and point it at an exact file, overriding just that one while the rest of the scope stays routed to the shared folder:

export default {
  '@acme':        '../shared/acme-schemas',      // the whole scope → a folder
  '@acme/person': './schemas/acme-person.yml',   // …but this one → a specific file
}
  • Precedence is most-specific first: a per-schema file beats a scope directory, which beats the @org/schemas package.
  • A routed scope does not fall back to the package for a missing schema — it errors, rather than silently load a different definition. Fill a gap with a per-schema key, not a fallback.
  • A scope value is a directory (the schema name is appended); a per-schema value is a file (the extension is optional).
  • @/ (self) and @uniweb (reserved) are never routable.
  • A key whose value is empty — for example, an unset environment variable — is skipped, and it falls back to the next source.
  • Foundations without a schemas.config.js resolve exactly as before.

Example: Event Listing

export default {
  title: 'Event Grid',

  // Renders event-shaped data; its shape comes from the standard event schema.
  data: { events: '@std/event' },

  content: {
    title: 'Section title',
    paragraphs: 'Intro text [1]',
  },

  params: {
    layout: {
      type: 'select',
      options: ['grid', 'list', 'calendar'],
      default: 'grid',
    },
  },
}

The component reads the events from content.data.events and renders them alongside markdown content.

One record, or a list

A data: entry describes the shape of each record. How many records the key holds is the entry's to say too:

data: {
  events: '@std/event',                             // a list of records
  event:  { schema: '@std/event', single: true },   // one record, or null
}
  • A list, by default. A section that shows a query's records receives every record it takes: content.data.events. When the query holds none, the key is [] — an answer with no records.
  • One record, with single: true. The key holds the first record its binding selects, or null when there is none. On a parametric page that is the record the URL names: content.data.event.

The runtime asks for one record for a single key, rather than the whole list. When the binding selects several, the key holds the first, so sort the query or give the binding limit: 1 to choose which. When the URL of a parametric page names no record, the page is marked not found, and its key is null ([] for a list key).

A key that holds a tagged data block's value holds what was written, and uniweb validate checks the block against the key: a list of records under a list key, one record under a single key. The shape the key declares also decides what of a translated site's block is translated: its text fields, never an enum, a URL or a translatable: false field (Internationalization). A key declared {} leaves that to a heuristic. A key nothing fills is null. Beyond what a key declares, the runtime does not reshape what it holds; reshape content.data once with a foundation handlers.data hook. See Data Fetching → What a section receives.

Briefs or whole records

A query's record reaches a component in one of two shapes, and the key says which the component expects (Entity Content → What a component receives):

data: {
  articles: '@std/article',                                         // briefs: the brief's fields at the top — article.title
  archive:  { schema: '@std/article', whole: true },                // whole records, as stored — article.brief.title, article.body.content
  article:  { schema: '@std/article', single: true, whole: true },  // the one article, whole
}
  • The default — each record is its brief: the fields of the schema's brief section at the top, the section not named. What a list, a card or a row needs.
  • With whole: true each record arrives whole: every section under its own name, the brief's included. What a page that shows one record in full needs — an article's body is not in its brief.

single and whole are independent, and the long form takes those two and schema, nothing else: the build stops on any other property, naming the key. A ref written alone, '@std/article', is the same as { schema: '@std/article' }.

The runtime asks for what the key declares — on a parametric page as much as anywhere — so a component never receives the other shape. A key may also name @uniweb/link or @uniweb/file, the schemas of a site's link records and file records: a link arrives as { url, $name, $label, $tags }, a file as { file: { url, name, mime, size }, $name, $label, $tags }. A key declared whole: true whose source cannot answer a whole record is null. A schema of one section (the fields: form) has a brief that is the whole record, so its component can leave whole off; records of an external query have no schema, and arrive as their source answers them. Either shape arrives as the records hold it: no field is filled with a default.

A schema whose root is a list, such as @std/nav or @std/form, describes the key's whole value: its key holds that list, and takes neither flag.

Loading states

When a data fetch runs at runtime, the component renders immediately — static content (title, paragraphs, items) is available on first render, and the declared key is null until the fetch completes.

The component checks block.dataLoading to know whether a fetch is still in progress:

function ArticleList({ content, block }) {
  if (block.dataLoading) {
    return <DataPlaceholder lines={4} />
  }

  const articles = content.data.articles || []
  return <ArticleGrid articles={articles} />
}

This prevents layout shifts — instead of the page collapsing while data loads, the component holds its space with a placeholder. See Kit Reference for block.dataLoading and DataPlaceholder.


Params

Parameters are configurable options set in frontmatter:

---
type: Hero
variant: glass
layout: split-right
---

Define them in params:

params: {
  variant: {
    type: 'select',
    label: 'Variant',
    hint: 'The hero’s visual style',  // optional guidance
    options: ['gradient', 'glass', 'minimal'],
    default: 'gradient',
  },
  showPattern: {
    type: 'boolean',
    label: 'Show background pattern',
    default: true,
  },
  maxItems: {
    type: 'number',
    label: 'Maximum items to display',
    default: 6,
  },
  customClass: {
    type: 'string',
    label: 'Custom CSS class',
    hint: 'Added to the section wrapper element',
  },
}

Runtime guarantees: Param defaults from meta.js are automatically applied by the runtime. Your component receives params with defaults already merged in—no need for variant || 'gradient' fallbacks.

Names a param can't take. Every key in a section's frontmatter is a param except these, which every section can be given, whatever its type. None reaches your component as a param, so don't declare one in params:

in a section's frontmatterwhat it isa component reads it from
type, id, hiddenthe section itself — its section type, its id, and a draft (hidden: true)—
query, fetchthe section's datacontent.data, under the keys data: declares
theme, background, grid, vars, stickythe section's own settings, which framework applies — the color context and the background around your component, the section's theme and your component's CSS variables in the page stylesheet, the child layout, and whether the section stays in view as the page scrollsblock, when it needs one for its own logic: useColorContext(block) for the context (a light or dark logo, say), <SectionBackground block={block} /> to draw the background itself, <ChildGrid from={block} /> for the grid, block.sticky
propsparams written as one mapparams — its keys are merged in
data, preset, inputnothing: data: is refused (it was query:'s old name); preset: and input: are ignored with a warning—

The build warns on a param declared as any of them. A preset may still set theme or background: a preset's params become the section's frontmatter when an author starts a section from it.

Param Types

TypeEditor UIValue
selectDropdownString from options
booleanToggletrue/false
stringText inputAny string
numberNumber inputNumeric value

Options Shorthand

When option value equals label, use strings:

// Full form
options: [
  { value: 'dark', label: 'Dark' },
  { value: 'light', label: 'Light' },
]

// Shorthand (value === label)
options: ['dark', 'light']

// Mixed
options: [
  'dark',
  'light',
  { value: 'glass', label: 'Glassmorphism' },
]

Vars

Component-level CSS custom properties, scoped to #section-{id}. These are distinct from params — vars become CSS variables that your component's styles can reference, while params are JS values passed as props.

vars: {
  'card-gap': {
    default: '1.5rem',
    label: 'Card Gap',
    type: 'select',
    options: ['1rem', '1.5rem', '2rem'],
    group: 'Layout',
  },
  'card-radius': {
    default: 'var(--radius-md)',
    description: 'Inherits from foundation var by default',
  },
}

The var schema is the same as foundation vars — default, label, type, options, group, description all work the same way.

CSS output

Component vars emit on the section's CSS selector:

#section-42 {
  --card-gap: 1.5rem;
  --card-radius: var(--radius-md);
}

Use them in your component's CSS or Tailwind classes:

function PricingTable({ content }) {
  return (
    <div className="grid gap-[var(--card-gap)]">
      {content.items.map((item, i) => (
        <div key={i} className="rounded-[var(--card-radius)]">
          {/* ... */}
        </div>
      ))}
    </div>
  )
}

Frontmatter overrides

Content authors override component vars in section frontmatter:

---
type: PricingTable
vars:
  card-gap: 2rem
---

Only vars declared in meta.js are emitted — unknown var names in frontmatter are ignored.

Foundation vars vs component vars

Foundation varsComponent vars
Declared inmain.jsmeta.js
CSS scope:root (global)#section-{id} (scoped)
Overridden bytheme.yml (vars:)Section frontmatter (vars:)
Context-aware typesYes (color, gradient)No (always context-independent)

Sticky

Whether a section of this type stays in view as the page scrolls, unless its author says otherwise:

// sections/Header/meta.js
export default {
  title: 'Header',
  sticky: true,
}

An author turns it off with sticky: false in the section's frontmatter (or on with sticky: true, for a type that declares nothing) — it is one of the section's own settings, so your component never receives it as a param.

The runtime makes sticky the box that can stay in view:

  • a section in the header (or another layout area) — the area's box, stacked above the areas that scroll under it;
  • a section in the body — its own wrapper, which stays in view below whatever is stuck at the top: a sub-navigation sits under a sticky header with nothing to declare;
  • a child section that its parent renders as a section (kit's <ChildGrid>, or <ChildBlocks wrapAs="div">) — its own wrapper, which stays in view within the box its parent places it in. In <ChildGrid> that is its row: a side panel stays beside a long column and leaves with it, and on a phone, where the grid is one column, it scrolls with the page. A child rendered bare has no box of the runtime's; the component reads block.sticky and draws it itself.

How far the stuck chrome reaches is measured in the browser and published as --uw-sticky-top. On a page whose header sticks, a link to a section (#section-…, as a search result or a contents entry writes it) lands below it, since each section in the body carries that much scroll margin. And a sticky section uses it unless its type sets a top of its own — one CSS length, say for a gap below the header:

export default {
  sticky: { default: true, top: 'calc(var(--uw-sticky-top, 0px) + 1rem)' },
}

A sticky top-0 on your component's own markup cannot stick: it sits in boxes as tall as itself, so it scrolls away with the page. Nor can a sticky section inside a box that clips with overflow: hidden — use overflow: clip, which clips the same and keeps it sticking. uniweb dev names both in the console. See Component Patterns.


Presets

Presets are pre-configured parameter combinations, which an editor offers by label — picking one sets those params on the section:

presets: {
  default: {
    label: 'Centered Hero',
    params: { variant: 'gradient', layout: 'center' },
  },
  glass: {
    label: 'Glassmorphism',
    params: { variant: 'glass', layout: 'center' },
  },
  minimal: {
    label: 'Minimal Dark',
    params: { variant: 'minimal', layout: 'left', showPattern: false, theme: 'dark' },
  },
}

In a file, write the params themselves. uniweb add section Hero --starter --preset glass prints starter content with that preset's params as its frontmatter:

---
type: Hero
variant: glass
layout: center
---

A preset may set one of the section's own settings too — minimal above pins the dark context with theme: dark.

A section keeps its params, not the name of the preset they came from: a preset: key in its frontmatter has no effect, and the build warns and ignores it.


Structured Author Data

The same data: keys that receive a query's records (above) also declare the shapes of structured data a component accepts directly from authors. There is no separate schemas: key — a data: entry is the one declaration surface, and a tagged block under a key the component does not declare is left out of content.data. Two input paths converge on the same content.data[key]:

  1. Tagged markdown blocks — authors write YAML/JSON in a fenced block prefixed with the key. Great for filesystem-edited projects.
  2. Form UI — the Uniweb editor renders a form from the schema. Great for non-technical authors editing a published site.

Both produce the same shape at content.data[key], so components are agnostic to how the data was entered. The value of a data: entry is either an inline field map (a keyed object, below) or an inline rich-form (a fields array — the editor form). It can also be a named ref ('@/…', '@std/…') when the shape is shared/versioned.

Concept blocks

A concept block is prose an author writes under a tag — a ```md:faq fence, or a GitHub alert (> [!WARNING]) — and it arrives at content.data.<tag> as { items, sequence } (Concept Blocks). Declare one by writing its key as the fence is written:

data: {
  'md:faq': 'Questions and answers [3+]',
}

The component still reads content.data.faq. The value is the author's label — its count counts the entries — or an object that also says what each entry holds:

data: {
  'md:faq': {
    label: 'Questions and answers [3+]',
    hint: 'One heading per question',
    content: { title: 'Question', paragraphs: 'Answer' },
  },
}

content: takes the vocabulary a section's does, for one entry — it holds neither items nor a sequence. A concept block has no schema, since its shape comes from the fence, so the value is never a schema ref. faq: {} still declares the key, with nothing said about the block.

Inline field map — flat key/value maps

Use a field map for flat row-like data (e.g. nav links). Fields are declared as a keyed object directly on the data: entry:

```yaml:nav-links
- label: Home
  href: /
- label: About
  href: /about
  type: button
```
// meta.js
data: {
  'nav-links': {
    label: { type: 'string' },
    href: { type: 'string' },
    type: { type: 'string', enum: ['plain', 'button', 'dropdown'], default: 'plain' },
    icon: 'string',  // shorthand for { type: 'string' }
    children: {      // one level of sub-links
      type: 'array',
      items: { type: 'object', fields: { label: 'string', href: 'string' } },
    },
  },
}
function Header({ content }) {
  const navLinks = content.data['nav-links'] || []
}

Rich-form schemas — ordered fields with labels, options, conditions

Use a rich-form schema when the data has an ordered field list (labels, required markers, conditional visibility, nested arrays). Rich-form schemas drive both tagged-block authoring and the FormBlock editor widget. The distinguishing marker is a fields array (instead of a keyed object), or any of isComposite / childSchema.

// meta.js
data: {
  stats: {
    name: { en: 'Stats', fr: 'Statistiques' },     // shown in editor
    isComposite: true,                              // value is an array
    childSchema: {
      name: { en: 'Stat', fr: 'Statistique' },
      fields: [
        {
          id: 'number',
          type: 'text',
          label: { en: 'Number', fr: 'Nombre' },
          required: true,
        },
        {
          id: 'text',
          type: 'text',
          label: 'Label',
          default: 'metric',
        },
      ],
    },
  },

  'side-content': {
    name: 'Side Content',
    fields: [
      {
        id: 'for',
        type: 'select',
        options: [
          { label: 'Scholar', value: 'scholar' },
          { label: 'News',    value: 'news' },
        ],
      },
      {
        id: 'department',
        type: 'text',
        condition: { for: 'scholar' },              // only when for=scholar
      },
      {
        id: 'headline',
        type: 'text',
        condition: { for: { $in: ['news', 'feature'] } },
      },
    ],
  },
}

Components read the filled data by key:

function Hero({ content }) {
  const stats = content.data.stats || []
  // [{ number: '42', text: 'users' }, { number: '1M', text: 'queries' }, ...]
}

Rich schema — field properties

PropertyTypeNotes
idstring (required)field identifier (data key)
typestring (required)see field types below
labelstring or {en,fr}shown in editor
descriptionstring or {en,fr}editor tooltip
placeholderstring or {en,fr}input placeholder
requiredbooleaneditor validation
defaultanywhat the editor's form starts the field with — never applied at runtime
options[{label, value}] or [string]for select
min / maxnumberfor number
conditionobjectvisibility predicate — see below
fieldsarrayfor type: nestedObject (inline nested fields)
childSchemaobjectfor type: form (nested composite array)

Rich schema — field types

  • text, email, textarea, number, checkbox, select — primitives
  • form — nested composite array; requires childSchema: { fields: [...] }
  • nestedObject — single nested object; requires fields: [...]

Rich schema — composite (isComposite: true)

The stored value is an array of rows; each row matches childSchema.fields. Add/remove rows in the editor, or author the array directly in a tagged markdown block.

Rich schema — conditions (editor-side)

Any field (root or nested) can declare a condition that controls visibility in the editor form:

{ id: 'department', type: 'text', condition: { for: 'scholar' } }
{ id: 'label',      type: 'text', condition: { for: { $in: ['a','b'] } } }

Supported operators: $eq, $neq, $in, $nin, $truthy, $falsy. Shorthand { key: value } is implicit $eq. Multiple keys are AND'd.

Conditions are a UX concern: the editor hides fields whose condition doesn't hold so the author sees a relevant form. At runtime, all stored fields reach the component as-is — there is no condition-based stripping. Components that render differently based on a controller field should branch on that field directly (e.g. data.for === 'news') and ignore what isn't relevant.

Localized labels

Anywhere a label, name, or description is accepted (on schemas, child schemas, fields, and option entries), you may pass either a plain string or an {en, fr, ...} object. The editor renders the user's active locale.

Inline field-map field types (keyed-object form)

An inline field map uses the same shape as a named data schema (the @/-ref form): nested objects nest via fields:, lists via items:, and a closed value set is enum:. A one-off map and a shared @/-ref schema are interchangeable — anything you write inline you can move into a foundation/schemas/*.yml file and reference by name, except default:. A default: is what an editor's form starts the field with, so it belongs only inline; a named schema declares none (the build refuses one), and the runtime applies neither — a component receives the data as written. It must be a valid data schema — an object field declares fields or values, and an opaque value is json (richtext for ProseMirror content) — or uniweb register refuses the foundation, naming the key; the build warns of it first.

// Full form
field: { type: 'string', default: 'value' }

// Shorthand
field: 'string'
field: 'number'
field: 'boolean'

// Closed value set (an inline list of allowed values)
field: { type: 'string', enum: ['a', 'b', 'c'], default: 'a' }

// Nested object — nests via `fields:`
field: { type: 'object', fields: { name: 'string', value: 'number' } }

// Array — element type via `items:`
field: { type: 'array', items: { type: 'string' } }
field: { type: 'array', items: { type: 'object', fields: { name: 'string' } } }

Insets

The inset flag declares that a component is available for inline @ComponentName references in markdown. Content authors can place it within another section's content:

![Architecture diagram](@NetworkDiagram){variant=compact}
// sections/insets/NetworkDiagram/meta.js
export default {
  title: 'Network Diagram',
  inset: true,
  params: {
    variant: {
      type: 'select',
      options: ['full', 'compact'],
      default: 'full',
    },
  },
}

Where an inset is offered is a concern of the section it is placed in — a media slot that takes embedded components, or a sequence that does not leave them out — not a property of the inset itself. (children is about child sections, not insets.) Don't use hidden on insets — hidden means "exclude from export entirely" (for internal helpers or work-in-progress components), so a hidden component is not a section type and @ComponentName cannot resolve to it.

A component with inset: true is meant to be placed as an inset: a visual editor offers it among insets and leaves it out of the list of sections. A component meant for both adds section: true:

// sections/Testimonial/meta.js
export default {
  inset: true,               // offered among insets in an editor
  section: true,             // and still offered as a section
}

This guides editors; it does not restrict markdown. The runtime renders any section type in either position.

Inset components receive content.title (from the [description] text) and params (from {key=value} attributes). At runtime, the parent section accesses insets via block.insets (separate from block.childBlocks).


Children (Composition)

The children field declares that a section type accepts file-based child sections. This is editor metadata — at runtime, block.childBlocks is always available regardless of whether children is declared.

// sections/Grid/meta.js
export default {
  title: 'Grid',
  description: 'Arrange components in a responsive layout',
  children: {
    label: 'Grid items',
    hint: 'Each child section becomes a grid cell. Use any component type.',
    grid: [3, 2, 4, '40/60', '60/40'],
  },
}

The object form takes label, hint (each a string, or an { en, fr, … } map), min, max, types (the section types allowed as children), and grid. Shorthands: children: true (any number, any type) or children: 3 (up to three).

Grid layouts

grid lists the layouts a component offers for its children. Each is a column count (3 — equal columns) or relative widths ('40/60' — two columns, the part count being the column count). The first is the layout a section gets when it chooses none.

A section chooses one with the grid key in its frontmatter — a section key like type, never a param:

---
type: Grid
grid: '40/60'
---

Lay the children out with kit's ChildGrid, which reads that choice from block.grid and uses your default when there is none:

import { ChildGrid } from '@uniweb/kit'

export default function Grid({ block }) {
  return <ChildGrid from={block} fallback={3} />
}

ChildGrid shows one column on narrow screens, a layout of up to two columns from the md breakpoint (a wider one as two equal columns), and the chosen layout from lg. headerRow makes the first child span every column; className replaces the default gap-8. Each child renders as a section, so its own theme: and background: apply. Don't declare a param named grid — the section key takes the name, so the param would never receive it.

In markdown, child sections use the @ prefix and nest: in page.yml:

pages/home/
├── page.yml
├── 1-intro.md          # type: Grid (parent)
├── 2-features.md       # type: Features
├── @text.md            # type: TextBox (child of intro)
└── @media.md           # type: Media (child of intro)
# page.yml
nest:
  intro: [text, media]

Or using inline nesting in sections::

sections:
  - intro:              # Grid
      - text            # Resolves to @text.md
      - media           # Resolves to @media.md
  - features            # Features

Cross-Block Communication

Section types sometimes need to coordinate with their neighbors. The typical case: a Header needs to know whether the section below it supports a floating translucent overlay. A Hero with a full-bleed background does; a plain text section doesn't.

The section that owns the capability declares it in its meta.js. The section that needs to adapt reads it via getNextBlockInfo(), getPrevBlockInfo(), or getFirstBodyBlockInfo().

context (static)

Declares permanent facts about the component type. Never changes at runtime.

// Hero/meta.js — "I always support a translucent header over me"
export default {
  context: {
    allowTranslucentTop: true,
  },
}
// Header/index.jsx — adapts based on what's below
const nextBlockInfo = block.getNextBlockInfo()
// nextBlockInfo.context  → static (meta.js)
const isFloating = nextBlockInfo?.context?.allowTranslucentTop || false

Use context when the capability is inherent to the component type — every instance of Hero always supports translucent headers, regardless of its content or state.

initialState (dynamic)

Declares an initial value that the component can change at runtime via useBlockState(). Neighbors see changes immediately.

// Hero/meta.js — starts translucent-ready, but component logic may disable it
export default {
  initialState: {
    allowTranslucentTop: true,
  },
}
// Hero/index.jsx — updates state based on runtime conditions
function Hero({ content, block }) {
  const [state, setState] = block.useBlockState(useState)
  // state.allowTranslucentTop is true initially (from meta.js)
  // Component logic can change it: setState({ allowTranslucentTop: false })
}
// Header/index.jsx — reads dynamic state, falls back to static context
const nextBlockInfo = block.getNextBlockInfo()
// nextBlockInfo.state    → dynamic (useBlockState)
const isFloating = nextBlockInfo?.state?.allowTranslucentTop
  ?? nextBlockInfo?.context?.allowTranslucentTop
  ?? false

Use initialState when the capability depends on runtime conditions — a Hero might disable the translucent overlay after a user interaction or based on loaded data.

Key names are yours to design

allowTranslucentTop, expanded, playing — these are not framework fields. Design whatever protocol your foundation's sections need to coordinate.


Design Principles

  1. Graceful degradation — Components handle missing content without errors
  2. Sensible defaults — Every param should have a good default
  3. Intent over implementation — Params describe purpose (variant: featured) not CSS (color: #1a1a1a)
  4. Minimal metadata — Only include what the editor needs; implementation details stay in code
  5. Composition over configuration — Use Grid + simple components instead of mega-components with many options

See Also