---
title: Components and slots
description: Components are named reusable subtrees declared once and referenced anywhere; slots project layout content into them, and states (some entered by hover or press) name sets of their values.
status: shipped
url: https://getairframe.com/framewerk/core/components
---

> Part of the Framewerk docs. Index of every page: /framewerk/llms.txt

# Components and slots

A **component** is a named, reusable layout subtree declared once under `components` and referenced anywhere by a `component` node; a **slot** projects layout *content* — not just string props — into it.

## Examples

A stat card with typed props:

**A stat card component**

```json
{
  "formatVersion": "1.0",
  "components": {
    "stat-card": {
      "summary": "One KPI with its label and trend, for a dashboard row.",
      "group": "Dashboard",
      "props": {
        "title": { "type": "string", "description": "What is measured" },
        "value": { "type": "string", "description": "The figure as displayed" },
        "change": { "type": "number", "description": "Percent change" },
        "trend": { "type": "string", "enum": ["up", "down", "flat"], "default": "flat" }
      },
      "defaults": { "title": "Metric", "value": "—" },
      "layout": {
        "_id": "stat-root",
        "type": "column",
        "gap": 4,
        "children": [
          { "_id": "stat-title", "type": "text", "content": "{{title}}", "variant": "caption" },
          { "_id": "stat-value", "type": "text", "content": "{{value}}", "variant": "heading" },
          { "_id": "stat-change", "type": "text", "content": "{{change}}% vs last month", "variant": "caption" }
        ]
      }
    }
  },
  "screens": [
    {
      "id": "dash",
      "layout": {
        "_id": "dash-root",
        "type": "row",
        "gap": 12,
        "padding": 16,
        "children": [
          { "_id": "k1", "type": "component", "ref": "stat-card", "props": { "title": "Revenue", "value": "$12,400", "change": 4 } },
          { "_id": "k2", "type": "component", "ref": "stat-card", "props": { "title": "Orders", "value": "318", "change": -2, "trend": "down" } }
        ]
      }
    }
  ]
}
```

An app shell with a slot, applied as a screen root:

**A shell with a body slot**

```json
{
  "formatVersion": "1.0",
  "components": {
    "app-shell": {
      "slots": ["body"],
      "layout": {
        "_id": "shell",
        "type": "column",
        "children": [
          { "_id": "shell-header", "type": "text", "content": "My App", "variant": "heading" },
          { "_id": "shell-body", "type": "slot", "name": "body" }
        ]
      }
    }
  },
  "screens": [
    {
      "id": "home",
      "layout": {
        "_id": "home-root",
        "type": "component",
        "ref": "app-shell",
        "slots": {
          "body": { "_id": "home-body", "type": "text", "content": "Welcome" }
        }
      }
    }
  ]
}
```

## Anatomy

A prop is interpolated into a text prop with `{{name}}` (single braces, `{name}`, also work). `props` declares the prop names and types; `defaults` supplies values used when an instance omits them.

**The prop list is the contract.** Whoever places a component — a person or a model — sees its name, its summary and its props, never its layout. So each prop declares its real `type` (`boolean` for an on/off, `number` for a count), a closed set of values is an `enum`, and a prop whose name would not tell a stranger what to pass has a `description`. A value that does not fit its type is a validation warning: `"yes"` on a boolean, `"3 pts"` on a number. A boolean accepts `"true"`/`"false"` or JSON `true`/`false`; a number accepts `"5"` or `5`.

**A prop carries the value; the layout carries the words.** `change: 4` with `"{{change}}% vs last month"` in the layout, not `change: "+4% vs last month"`. The words then stay consistent across every instance and can be translated.

**A colour is a tone, never a hex.** A component that shows a status takes an `enum` and lets the style choose the colours, so they follow the theme into both modes: `tone: "success"` resolves to `var(--success-light)` before style tokens do.

**A list is one prop.** Declare the shape of one row in `items`, and repeat over the list with `forEach: "$p(<prop>) as <row>"`.

**A prop value may read state.** The fenced spelling, `{$name}`, in an instance's prop resolves against the state the instance is rendered in: `"props": { "total": "{$total}" }`. One instance then shows every value and follows a press, where a screen would otherwise stack one instance per value behind `showWhen`. Screen state, [app state](/framewerk/core/state-and-expressions.md#app-state) and `$env` all read this way. Only the fenced form is a value; a bare `$role == coach` in a prop is an expression, forwarded as written and evaluated where it lands. A name nothing declares passes absence and is a validation warning.

**A state is a name for a set of values.** `states` is an ordered list of `{ name, props }`. Each key in `props` is a prop the component declares or one of its own `stateVars`, so a menu held in a state variable has an open state as easily as a banner has an error state: `{ "name": "switcher-open", "props": { "switcher": "true" } }`. An instance pins one with `"state": "switcher-open"`, and inside the component `$state.switcher-open` reads `"true"`. Precedence is defaults, then the pinned state, then the instance's own props. A key declared as neither, a value outside a state variable's `options`, and a name declared as both a prop and a state variable are validation warnings.

**A state can show or hide a layer.** A node's `overrides` read `$state.<name>`, so one component can hold looks that differ in their layers, not only in their values: a layer only one state draws carries `"hidden": true` and an override `{ "when": "$state.hover == true", "props": { "hidden": false } }`, and a layer that state does not draw is hidden by `{ "when": "$state.hover == true", "props": { "hidden": true } }`. With no state pinned the component draws its layers as written. A Figma variant set imports this way when its variants add or remove layers. An override on a nested `component` node may set its `state`, so a state can show a nested component in another of its own states.

**`null` removes a prop.** In a node's `overrides` entry, or in a state's `props`, a value of `null` takes the prop away while the override applies or the state is pinned, so the node draws as if it had never been set; a key left out changes nothing (JSON Merge Patch). A disabled input that draws no fill is `{ "when": "$state.disabled == true", "props": { "background": null } }` on a row whose `background` is `"rgba(0,0,0,0.45)"`. In a state's `props`, `null` takes away the component's default for that prop; a value the instance writes itself still wins. A `null` for a prop the node does not set, and that no other override on it sets, removes nothing and is a validation warning. A Figma variant that lacks a property its set's most-used variant has imports this way.

**A state can be entered by the pointer.** A state with `"interaction": "hover"` is shown while the pointer is over an instance, and one with `"interaction": "pressed"` while the pointer is down on it; when the pointer leaves or lets go, the instance goes back to the state it pins. A set with a hover per resting look says which one each hover is entered from: `{ "name": "danger-hover", "interaction": "hover", "from": "danger" }`. A hover without `from` is entered from any state that has no hover of its own. This happens only in a played prototype; a capture pins it with `$forceState` `<instance id>:hover` or `<instance id>:press`. `focus` is reserved, and nothing enters it yet.

**Each instance keeps its own state.** A `setState` inside an instance on a variable the component declares in its `stateVars` changes that instance only, so two switches on one screen flip separately. A variable the component does not declare is the screen's, and the write changes it for everything that reads it.

A component may also carry a `summary`: what it is for, and when to use a sibling instead. A component has no title and no route — only the key it is stored under — so that sentence is what separates `card` from `athlete-card`. Aim for 100 words or fewer.

A `group` says where the component sits in the component library: `"Dashboard"`, or a nested path like `"Forms/Inputs"` (Inputs inside Forms). A component without one is listed under Ungrouped.

Reference it with a `component` node: `{ "type": "component", "ref": "stat-card", "props": { … } }`.

**Instance overrides.** Layout props set on the instance are merged onto the component's resolved root, so one definition can adapt without a variant explosion.

**Slots.** `ComponentDef.slots` lists the slot names a component offers; a `slot` node names one with `name` and may carry a `fallback` node, rendered when the instance projects nothing into it. Alternatively, mark any container with `slot: "body"` to make it an inline slot target — projected content replaces its children while the container keeps its own layout props. A `component` node can be a screen's root, which is exactly how the shell above is applied.

**Varying one part without a slot.** `nodeOverrides` on an instance edits a node of the definition by its `_id`; the value is the same prop bag a plain node takes, and it merges rather than replaces. It may not set `_id`, `type` or `children` — that is what slots are for. An address the definition does not contain warns at validation with the available ids. A bag may carry `overrides`, which replaces that node's conditional list for this instance only (the root is addressed by its own `_id`). A pinned state's override wins over the instance's own props, so an instance that keeps its own value for a prop its state also sets carries the node's list without that state's entry for the prop; the Figma importer writes this.

## Stability

Component and slot fields (`props`, `defaults`, `slots`, `nodeOverrides`, `forEach`) are part of the format contract. Adding a field is additive; changing how `{{…}}` interpolation or slot projection resolves is breaking.
