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.datakeys 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:
| Field | Required | Default |
|---|---|---|
title | No | Inferred from component name (TeamRoster → "Team Roster") |
description | No | — |
family | No | — (the editor falls back to a generic illustration) |
hidden | No | false |
background | No | false |
sticky | No | false |
inset | No | false |
children | No | — |
data | No | — (the section receives no data of its own) |
content | No | — |
params | No | — |
presets | No | — |
vars | No | — |
context | No | — |
initialState | No | — |
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.
| Source | Declared in | Accessed via |
|---|---|---|
| Markdown content | content: { ... } | content.title, content.paragraphs, content.items |
| Frontmatter params | params: { ... } | params.paramName |
| A query's records | data: { events: '@std/event' } | content.data.events |
| Structured author data | data: { 'nav-links': { ... } } | content.data['nav-links'] |
Note:
block.dataandcontent.datapoint at the same object at runtime. Usecontent.data.*in components — it matches how the rest of the framework surfaces the content tree.
Schema Reference
Identity Fields
| Field | Type | Description |
|---|---|---|
title | string | Display name in the editor. If omitted, inferred from the component name (TeamRoster → "Team Roster") |
description | string | What the component does |
family | string | The standard section family this component belongs to. Picks the editor's illustration, its icon and a label it can translate. Optional — see below |
hidden | boolean | If true, component is excluded from export entirely (internal helpers, not-yet-ready components) |
inset | boolean | If 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.
| Group | Families |
|---|---|
| Opening — say what this page is | hero · profile · statement · announcement · countdown · marquee · canvas |
| Explaining — your own words and pictures | article · rich-text · story · editorial · figure · gallery · video · code-block · callout · steps · process · roadmap · faq |
| Listing — show many of something | card-grid · team · products · jobs · schedule · menu · social-feed · changelog · downloads · bibliography · teaser · recommendations |
| Convincing — evidence and proof | features · testimonials · evidence · logo-cloud · integrations · spotlight · hotspots · pricing · stats · metrics · data-table · comparison |
| Acting — get the visitor to do something | cta · contact · newsletter · booking · wizard · quiz · calculator · estimate · search · auth · paywall · app |
| Navigating — help them get around | header · footer · pathways · toc |
| Embedding — bring in something external | map · embed · raw-code · widget |
| Organizing — arrange other content on the page | grid · 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:
| Form | Example | Means |
|---|---|---|
| A label | 'Headline' | The element, named for the author |
| A label with a count | 'Cards [3-6]' | …and how many — see Count Syntax |
true | title: true | The 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:
| Syntax | Meaning |
|---|---|
'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>:
| Element | Source | Description |
|---|---|---|
title | H1 | Main headline |
pretitle | A #> label line | Eyebrow/kicker text |
subtitle | Heading after title | Secondary headline |
paragraphs | Body text | Description paragraphs |
links | [text](url) | Markdown links (become buttons/links) |
lists | - item | Bullet or numbered lists |
items | Subsequent headings | Content groups within the markdown — see What each entry holds |
media |  on its own line | An image, a video, or an embedded component — see Media |
icons |  | Icons an author picks |
documents | {role=pdf} | Files, with an optional preview |
snippets | ```js | Code samples |
tables | A markdown table | Tables |
math | $ … $ | Displayed equations |
quotes | > … | Block quotes |
sequence | The whole section | Content 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:
| Spelling | Is |
|---|---|
image, images, thumbnail | media with types: ['image'] |
videos | media with types: ['video'] |
insets | media 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(oricon). - A document is not media — declare
documents. A{role=pdf}file arrives incontent.documents, never among the images. - An image inside a sentence belongs to the text. It arrives in
content.imagesand incontent.sequence, but not incontent.media. - Declare a thing once.
mediabesideimagewould count one image toward both; the build says so.
visualsis retired. The slot is themediaelement:visuals: 1ismedia: '… [0-1]', andvisuals: { types: ['image'], count: 'many' }ismedia: { label: '…', types: ['image'] }. The build refuses a top-levelvisualsand 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.titlebeside it says the section should begin with one; it does not mean the title renders apart from the rest. exceptlists 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
sequencemeans 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 },
}| Written | Means |
|---|---|
placement: true | left, 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 out | none |
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]',
},
}| Value | Behavior |
|---|---|
'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 out | The 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 formfetch: { query: articles, current: exclude, limit: 3 }in a.mdfile 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:
| Ref | Description |
|---|---|
@std/person | Team members, authors, people |
@std/article | Blog posts, news items |
@std/event | Calendar events |
@std/project | Portfolio/case studies |
@std/publication | Academic papers, research |
@std/opportunity | Jobs, grants, calls |
@std/service | Services a business offers |
@std/product | Things a business sells |
@std/testimonial | What a customer, client or patient said |
@std/position | A role someone held: a job, an appointment or a volunteer post |
@std/qualification | A 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/schemaspackage. - 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.jsresolve 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, ornullwhen 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: trueeach 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 frontmatter | what it is | a component reads it from |
|---|---|---|
type, id, hidden | the section itself — its section type, its id, and a draft (hidden: true) | — |
query, fetch | the section's data | content.data, under the keys data: declares |
theme, background, grid, vars, sticky | the 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 scrolls | block, 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 |
props | params written as one map | params — its keys are merged in |
data, preset, input | nothing: 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
| Type | Editor UI | Value |
|---|---|---|
select | Dropdown | String from options |
boolean | Toggle | true/false |
string | Text input | Any string |
number | Number input | Numeric 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 vars | Component vars | |
|---|---|---|
| Declared in | main.js | meta.js |
| CSS scope | :root (global) | #section-{id} (scoped) |
| Overridden by | theme.yml (vars:) | Section frontmatter (vars:) |
| Context-aware types | Yes (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 readsblock.stickyand 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]:
- Tagged markdown blocks — authors write YAML/JSON in a fenced block prefixed with the key. Great for filesystem-edited projects.
- 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
| Property | Type | Notes |
|---|---|---|
id | string (required) | field identifier (data key) |
type | string (required) | see field types below |
label | string or {en,fr} | shown in editor |
description | string or {en,fr} | editor tooltip |
placeholder | string or {en,fr} | input placeholder |
required | boolean | editor validation |
default | any | what the editor's form starts the field with — never applied at runtime |
options | [{label, value}] or [string] | for select |
min / max | number | for number |
condition | object | visibility predicate — see below |
fields | array | for type: nestedObject (inline nested fields) |
childSchema | object | for type: form (nested composite array) |
Rich schema — field types
text,email,textarea,number,checkbox,select— primitivesform— nested composite array; requireschildSchema: { fields: [...] }nestedObject— single nested object; requiresfields: [...]
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:
{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 # FeaturesCross-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 || falseUse 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
?? falseUse 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
- Graceful degradation — Components handle missing content without errors
- Sensible defaults — Every param should have a good default
- Intent over implementation — Params describe purpose (
variant: featured) not CSS (color: #1a1a1a) - Minimal metadata — Only include what the editor needs; implementation details stay in code
- Composition over configuration — Use Grid + simple components instead of mega-components with many options
See Also
- Content Structure — How content is parsed and structured
- Kit Reference — Hooks, components, and utilities from @uniweb/kit
- Foundation Configuration — CSS variables and custom Layout
- Data Fetching — Loading external data into components