Kampa component format 0.4
Status: 0.4, pre-release. The shape may change before a stable release. Nothing here promises compatibility across versions.
This file explains 0.4 component documents under tokens/components/: what
they are, how to read one, and what the Kampa check enforces on them.
component.schema.json beside it is the
machine-readable version. You need neither Kampa nor this file to read a
component: every file is plain DTCG.
Key words MUST, MUST NOT, MAY and SHOULD are used in the RFC 2119 sense. Section numbers like "Format §7" point to the DTCG 2025.10 Format and Resolver Module specifications.
1. What changed from 0.3
A 0.4 component can be more than one element. Everything 0.3 says still holds, per element.
- Parts. A component lists the elements inside it, in order, as data: a Card's title and body, an Accordion's items, a Table's rows and cells.
- A state held by one element can style another. An open item styles its trigger; an invalid control styles the message under it.
- Nesting. A part can be another component at a chosen variant: a Dialog's footer holds two Buttons, a Field holds an Input.
- Surfaces. A part's colours are graded on the surface it actually sits on, not only on the page.
- Inherited text is graded. A part that sets no text colour shows its parent's, and the check grades that.
- No fill. A fill can be fully transparent (
color.transparent), and the check then grades what shows through. - Base styles. A push with a 0.4 component also writes
base.css, page defaults you can choose to load.
A 0.3 document is unchanged: it validates, grades and pushes exactly as
before. A document is on 0.4 only when it declares format: "0.4". A 0.3
document with its format changed to "0.4" is a 0.4 document with no parts,
and means the same thing.
2. Where the files are
tokens/
light.json semantic tokens, one file per mode
dark.json
tokens.css generated: your tokens as custom properties
components.css generated: every component's CSS
base.css generated: page defaults, written beside a 0.4 component
components/
accordion.json the component file
accordion.resolver.json the resolver document it names
accordion.html generated: the snippet for each variant
LICENSE-icons.txt generated: the icon set's licence, when a snippet has an icon
schemas/component/0.4/ this file and the JSON Schema
3. Parts
parts in the descriptor is a list of part entries, in document order. A part
entry can hold its own parts, so the list is a tree that matches the HTML.
| Key | Meaning |
|---|---|
name |
Required. Lowercase letters, digits and single hyphens. Unique in the component. It becomes the class kampa-<component>__<name>. |
element |
The part's HTML element (§3.2). Required unless the part nests a component. |
attributes |
The part's fixed attributes, from an allowlist (§3.2). |
parts |
The part's own parts. |
repeat |
true: the part occurs one or more times, a list item, a row, a page link. Its styles apply to every instance. |
optional |
true: the part may be left out, a Card footer, an Alert icon. Its pairs are graded either way. |
use, input |
The part is another component (§5). |
A part's slots live in a group named for the part, directly under the component group:
"trigger": { "fg": …, "bg": …, "hover": { "bg": … } }
A part name cannot be a slot name, a state name, a pseudo-element name,
page, or one of the names kept for later formats (after, marker,
details-content, picker, picker-icon, checkmark, anything starting
at-).
Endpoints are fully qualified. bg is the root's fill, title.bg the
title part's, trigger.before.border-color a part's pseudo-element slot.
3.1 The structure is part of the contract
Parts are direct children of their parent part, exactly as the parts tree
says. Put your own content inside a part with no parts of its own, never
between two parts. Every rule in components.css is chained from the
component's root, so markup that breaks the structure loses the parts'
styles all at once rather than half of them.
3.2 Elements and where they may sit
| Element | Fixed attributes | States | Holds text |
|---|---|---|---|
div |
when it has no parts | ||
p, h1 to h6, label, caption |
yes | ||
nav, ul, ol, table, thead, tbody, tr, dialog, form |
form: method="dialog" |
no | |
li, td |
when it has no parts | ||
th |
scope: col or row |
yes | |
details |
open | no | |
summary |
hover, active, focus-visible | yes | |
span |
aria-hidden="true", only on an icon with no parts |
yes, unless hidden | |
a |
hover, active, focus-visible, current, disabled | yes | |
button |
type="button"; command="close" |
hover, active, focus-visible, disabled | yes |
A root can also be any 0.3 element, with 0.3's states. A heading part declares
typography, so its size never depends on the browser. A part that declares
border-width declares border too.
HTML moves elements that sit in the wrong place, and the moved paint would not
be what the check graded. So the check refuses a part placed against HTML's
content model: a summary is the first part of a details, a caption the
first part of a table; li sits in ul or ol; rows sit in thead or
tbody, cells in rows; p, headings and label hold only inline content; a
link, a button, a summary or a label holds nothing interactive; a form is
never inside another form.
4. States held by one element, styling another
A path in the component group reads left to right. A part name moves to that part; a state name is held by the part you are on; the last part named is the one painted.
| Path | Reads |
|---|---|
trigger.hover.bg |
the trigger's fill while the trigger is hovered |
item.open.trigger.fg |
the trigger's text while its item is open |
control.invalid.message.fg |
the message's text while the control is invalid |
select.checked.row.bg |
the row's fill while its checkbox is checked |
Rules, each with one spelling:
- A part named before the last holds at least one state.
- Holders come in document order (outer before inner, earlier before later), then the painted part with its own states.
- At one holder, states nest in the canonical order of 0.3, at most one of hover, active and focus-visible; at most three states on a path.
- For each painted part, a state name has one holder.
- A holder that is not inside the painted part's chain must be the only one of its kind there: a state inside a repeated part cannot paint something outside that part, because one instance would paint every instance.
The tiles the check grades are the rest and every condition the file declares, on every element. Which declaration wins is 0.3's rule, per element. A disabled element drops its own hover, active and focus-visible, nobody else's.
5. Nesting
{ "name": "control", "use": "component.input", "input": { "size": "md" } }
useis the path of a component group anywhere in your project, in any component file. The child must be format 0.3 or later.inputchooses one context for every one of the child's modifiers excepttheme: the child follows the page's mode.- A nested part has no element, parts or slots of its own: it is the child,
styled by the child's own document. The parent may read the child's states
(
control.invalid.message.fg) and, on a nested Button, settypetobuttonorsubmit. - The nested element carries both classes: the child's (
kampa-input) and the part's (kampa-field__control). - Nesting cannot form a cycle.
6. Where a pair is graded
on: "page" means the surface under the element that paints the slot side:
the nearest element above it with an opaque fill, else your project's general
surfaces (color.background.default, subtle, muted, and surface.*),
worst case first. For a component's root that is exactly 0.3's page.
- Fully transparent fills. A fill whose colour has alpha exactly 0 paints
nothing, so a pair on it is graded on the surface under it. Your projects
have
color.transparentfor this. A colour with alpha between 0 and 1 cannot be measured, and blocks. Text or a ring that is itself transparent blocks: an invisible indicator is not a passing one. - Inherited text. A part that holds text and sets no
fgshows its parent's text colour, and the check grades that colour on the part's fill or the surface under it. The pull request lists these as inherited text. - Nested components are graded twice: on their own, on your general surfaces, and in the parent, on the surface they actually sit on, every state of the child against every surface under it.
- Disabled excludes only the pairs the disabled element paints, or that sit inside it.
- Focus rings next to other parts. A ring sits outside its element and
reaches outline-offset plus outline-width past it. The check walks up from
the focusable part. At each level, the boxes beside it (the other parts of
its parent, and the next instance when it repeats) are neighbours, unless
the parent's
gapis at least the ring's reach; a neighbour's fills are graded against the ring at 3:1. When the parent haspadding-inlineandpadding-blockboth at least the ring's reach, the ring stays inside it and nothing further up is a neighbour. Padding on a table row or row group holds nothing: CSS gives them none. So the fix forring-overlapis agapon the part that lays the boxes out, or padding on both axes on a part between the ring and the box, such as a table cell. - The dialog backdrop. A
dialogmay paint abackdropgroup (background-color,opacity). Every pair naming it is decorative, and the dialog's own fill is opaque in every state, so nothing graded sits on the backdrop.
One rule keeps every pair honest. A condition that changes one written side of a pair changes all of them, including a fill the pair sits on. When no path can write that cell (a state on a part can never style the root), the check refuses the document and says which condition and which fill. A focus ring is the exception: it is graded in every combination of the state that draws it with every state that changes a fill under it, so neither has to repeat the other.
7. Base styles
base.css sets page defaults with your tokens: the page's colours and text
style, headings, paragraphs and lists, links, the focus ring, hr and
small. It is written only beside a 0.4 component, and only when every token
it names exists in every mode. Nothing imports it: load it if you want it. It
sits in the cascade layer kampa.base, below kampa.components and below
every style of yours that is not in a layer. Its colours are graded on your
general surfaces.
8. What the Kampa check enforces
Everything 0.3's file lists, per element, and:
| Code | Means |
|---|---|
part-entry-invalid, part-name-reserved |
a malformed part entry, or a name that cannot be a part |
part-path-noncanonical |
a path that is not the one spelling of its condition |
content-model-violation |
a part where HTML would move it |
state-holder-ambiguous, state-holder-repeated |
a state name with two holders, or a holder under a repeated part |
pair-incomplete, pair-closure-unspellable |
a condition that changes one side of a pair but not another |
nested-component-missing, nested-component-format, nested-input-unknown, nested-part-anatomy, nested-part-styled |
nesting that does not resolve, or a parent setting what the child owns |
ring-overlap |
a focus ring that can cover a neighbour's fill it does not stand out from |
typography-missing, border-color-missing |
a heading with no type style, a border with no colour |
dialog-surface-not-opaque, opacity-on-graded-paint |
a dialog that is not opaque, a graded pair on the backdrop |
slot-path-collision |
two cells that become one name in a Style Dictionary build (a warning in 0.3) |
9. Worked example
An Accordion: an item that repeats, a trigger, a panel, and a state held by the item that styles the trigger.
{
"component": {
"accordion": {
"$description": "Sections that open and close.",
"$extensions": {
"com.kampa": {
"component": {
"format": "0.4",
"element": "div",
"resolver": "./accordion.resolver.json",
"parts": [
{
"name": "item",
"element": "details",
"repeat": true,
"parts": [
{ "name": "trigger", "element": "summary" },
{ "name": "panel", "element": "div" }
]
}
],
"pairs": [
{ "slot": "trigger.fg", "on": "trigger.bg", "kind": "text" },
{ "slot": "trigger.outline-color", "on": "page", "kind": "non-text" },
{ "slot": "item.border", "on": "page", "kind": "decorative" }
]
}
}
},
"fg": { "$type": "color", "$value": "{color.foreground.default}" },
"gap": { "$type": "dimension", "$value": "{spacing.xs}" },
"item": {
"border": { "$type": "color", "$value": "{color.border.default}" },
"border-width": { "$type": "dimension", "$value": "{borderWidth.thin}" },
"open": {
"trigger": {
"fg": { "$type": "color", "$value": "{color.foreground.default}" },
"bg": { "$type": "color", "$value": "{color.background.subtle}" }
}
}
},
"trigger": {
"fg": { "$type": "color", "$value": "{color.foreground.default}" },
"bg": { "$type": "color", "$value": "{color.transparent}" },
"typography": { "$type": "typography", "$value": "{typography.labelMd}" },
"padding-block": { "$type": "dimension", "$value": "{spacing.xs}" },
"hover": {
"fg": { "$type": "color", "$value": "{color.foreground.default}" },
"bg": { "$type": "color", "$value": "{color.background.subtle}" }
},
"active": {
"fg": { "$type": "color", "$value": "{color.foreground.default}" },
"bg": { "$type": "color", "$value": "{color.background.muted}" }
},
"focus-visible": {
"outline-color": { "$type": "color", "$value": "{color.border.focus}" },
"outline-width": { "$type": "dimension", "$value": "{borderWidth.medium}" },
"outline-offset": { "$type": "dimension", "$value": "{borderWidth.medium}" }
}
},
"panel": {
"padding-block": { "$type": "dimension", "$value": "{spacing.xs}" }
}
}
}
}
{
"version": "2025.10",
"name": "accordion",
"sets": { "slots": { "sources": [{ "$ref": "./accordion.json" }] } },
"modifiers": {
"theme": {
"default": "light",
"contexts": { "light": [{ "$ref": "../light.json" }], "dark": [{ "$ref": "../dark.json" }] }
}
},
"resolutionOrder": [{ "$ref": "#/modifiers/theme" }, { "$ref": "#/sets/slots" }]
}
item.open.trigger.bgis held by the item (adetailselement is open) and paints the trigger. It changestrigger.fgtoo, becausetrigger.fgontrigger.bgis a pair and both its sides are changed somewhere.- At rest the trigger's fill is
color.transparent, so its text is graded on the surface under the trigger: nothing above it has a fill, so your general surfaces. - The panel sets no text colour, so its text is the root's
fg, graded as inherited text. - Tiles, per mode: rest, trigger hover, trigger active, trigger focus-visible, and item open.
10. Known limits
- Colours with transparency other than fully transparent cannot be graded: no compositing.
- A component you place inside a part by hand (a Button in an Alert's body),
and
base.cssstyles inside a part, are graded on your general surfaces only. - Responsive behaviour and motion are not in 0.4.
- The browser draws a
select's open list; it is not graded.
11. Every key Kampa adds
0.3's eleven keys, and six inside a part entry: parts, name, repeat,
optional, use, input. Seventeen in all. element, attributes and
role inside a part are the same keys as on the root.