---
description: @box-kite/react (Box Kite): the runtime CSS-in-JS library whose Box component takes 235 typed CSS props. Read before writing Box props, a component, a theme or an extension — the prop names collide with Tailwind and Chakra while the numbers mean different things.
globs: **/*.tsx,**/*.jsx,**/*.ts
alwaysApply: false
---

# @box-kite/react rules

This library is not in your training data: it was renamed at 1.0.0 (September 2026) and its prop surface nearly doubled on the way. Four facts before the rules, because each one is silently wrong in the APIs this most resembles:

- **The dividers are per prop.** Spacing is ÷4 (`p={4}` is 1rem), `fontSize` is ÷16 (`fontSize={14}` is 14px), `borderRadius` is ÷4, and border width and `lineHeight` are direct px (`b={1}` is 1px).
- **There is no `style` attribute.** Every visual value is a prop; the one-off nothing has a prop for is `css={{ … }}`, which still compiles to a shared class.
- **The element comes from the component, not from `tag`.** `<Flex>`, `<Button>`, `<H1>`, `<Ul>`, `<Path>` — `<Box tag="…">` is the fallback, not the idiom.
- **HTML attributes go in `props`.** `<Link props={{ href: '/about' }}>`; an `href` written at the top level typechecks and is then dropped.

1. **NEVER use `style={{ }}`** — always use Box props. Missing prop? Create with `Box.extend()`
2. **NEVER `<Box tag="...">`** for common elements — use `<Button>`, `<Link>`, `<H1>`, `<P>`, `<Ul>`/`<Li>`, `<Nav>`, `<Flex>`, `<Grid>`, and for SVG `<Svg>`, `<Path>`, `<Circle>`, `<Rect>`, `<SvgText>`… from `components/svg`
3. **An icon from lucide/Tabler/react-icons goes in `<Icon>`** (`components/icon`) — `<Icon size={5} color="amber-500"><Sun /></Icon>`. `size` is the ÷4 scale (the set's own `size` prop is pixels), and no `label` means `aria-hidden`. SVG you draw yourself is `<Svg>`, not `<Icon>`. An icon from any other set comes through the same `<Icon>`: `unplugin-icons` (`~icons/<set>/<name>`) at build time, `@iconify/react` when the name is data
4. **NEVER `<Box display="flex/grid">`** — use `<Flex>` / `<Grid>` components
5. **fontSize divider is 16** (not 4): `fontSize={14}` → 14px
6. **Spacing divider is 4**: `p={4}` → 16px (1rem)
7. **Border width and lineHeight are direct px**: `b={1}` → 1px. **borderRadius uses divider 4**: `borderRadius={2}` → 8px
8. **A gradient fill is a value, not an attribute**: `fill="url(#sky)"`, `stroke="var(--chart-1)"`, `clipPath="url(#frame)"` — so it can be themed and hovered. **A dashboard shape is `components/chart`**: `<Sparkline data={[…]}/>`, `<ProgressRing value={0.6}/>`, `<Gauge>`, `<MiniDonut>` — Box props, no chart library
9. **A CSS variable is a prop too**: `vars={{ 'color-revenue': 'sky-500' }}` declares `--color-revenue` on the element and everything inside it — the answer for markup this library does not render. **A third-party chart goes in `<ChartContainer series={['revenue', 'cost']}>`** (`components/chart`), which declares `--chart-1..6` in both themes and one `--color-<series>` per series, so `<Line stroke="var(--color-revenue)"/>` is all the chart ever says about colour
10. **SVG lengths have no divider and no unit**: `strokeWidth={2}` → `stroke-width: 2` (user units), same for `strokeDasharray`/`strokeDashoffset` and the geometry props `cx`/`cy`/`r`/`rx`/`ry`/`x`/`y`. `<Rect width={40} height={40}>` and `<Path d="M…">` take those as the SVG attributes they are — the Box props of those names mean something else
11. **HTML attributes go in `props` prop**: `<Link props={{ href: '/about' }}>` not `<Link href>`
12. **Size shortcuts**: `width="fit"` = 100%, `width="fit-screen"` = 100vw, `width="1/2"` = 50%
13. **An animation is a prop**: `animation="spin|pulse|bounce|ping"` (keyframes included, and they stop under reduced motion on their own), `Box.keyframes({ name: { from, to } })` for your own — its steps are Box props. **Times are milliseconds**: `animationDuration={1100}`, `transitionDelay={150}`. `transition` takes a group (`colors`, `transform`, `opacity`, `shadow`, `size`, `filter`), and `translateX`/`translateY`/`rotate`/`scale` are longhands that compose. **A spring is two props**: `transitionTimingFunction="spring-bouncy"` with `transitionDuration="spring-bouncy"` (also `spring`, `spring-gentle`, `spring-snappy`; `Box.spring({ stiffness, damping })` for your own)
14. **An entrance is a prop too**: `startingStyle={{ opacity: 0, translateY: 2 }}` is what a just-mounted element starts from (`@starting-style`, plain props only — nest a breakpoint or a theme _around_ it). Going out, React unmounts too fast to animate: either hide instead with `display` + `transitionBehavior="allow-discrete"`, which flips `display` at the _end_, or hold the node with **`<Presence present>`** (`components/presence`) — a render prop given `{ present, ref, props }`, where `ref` goes on the element carrying the transition. `interpolateSize="allow-keywords"` on a container is what makes `height: auto` animate (Chromium-only, snaps elsewhere)
15. **`data-*` and `aria-*` are attributes, so they go in `props`** — `props={{ 'data-state': 'open' }}`. Written at the top level (`<Box data-state="open">`) they typecheck and are silently dropped
16. **A state your own code sets is a nested prop too**: `dataAttr={{ 'state=open': { … } }}` → `[data-state="open"]` (a bare key is presence: `[data-loading]`), `ariaAttr={{ selected: { … } }}` → `[aria-selected="true"]`, `has={{ ':checked': { … } }}`, `not={{ hover: { … } }}` — keyed by state name, so it stays typed — and `nth={{ odd: { … }, 'last 1': { … } }}` → `:nth-child(odd)`/`:nth-last-child(1)`, which also takes `first`/`last`/`only`/`even` and an `An+B` formula. The attribute itself still goes in `props`; everything else (a breakpoint, a theme, a group, `startingStyle`) nests around them in either direction. A key the grammar rejects drops its whole block, the way an unmatched value does
17. **A pseudo-element is a nested prop, and CSS allows one**: `before`/`after` (which come with `content: ''`, because a generated element with none renders nothing), `placeholder`, `selection`, `marker`, `firstLine`, `firstLetter`, `backdrop`, `fileButton`. It is a slot, not a list — a second nested one is a type error — and it lands last on the element's own selector, so it composes with everything else in either direction. **`content` takes text and quotes it for you** (`content="New"` → `content: "New"`); `attr()`/`counter()`/`url()`/`var()` and a quoted sequence (`'"Step " counter(step)'`) are written out as CSS. `marker` and `selection` reach descendants, so they go on the `<Ul>` or the `<P>`. On a `Textbox`/`Textarea` a `placeholder` **string** is the attribute and an object is the styles; for both, the text goes in `props`
18. **A forced-colors mode keeps only the system colours**, so they are values on every colour prop: `forcedColors={{ bgColor: 'ButtonFace', color: 'ButtonText' }}` (also `Canvas`, `CanvasText`, `Highlight`, `HighlightText`, `GrayText`, `LinkText`). They are keywords, not tokens — a state signalled by fill alone needs them or it reads identically on and off

19. **A component that answers to its own space uses `cq`, not a breakpoint**: `container` makes an element a query container (`true`, or a name — both mean `container-type: inline-size`; `containerName`/`containerType` are the longhands), and `cq={{ md: { d: 'row' } }}` → `@container (min-width: 28rem)`. Six sizes, `xs` 20rem through `xxl` 42rem — **the container scale, not the breakpoint one** — each with a complement (`maxMd` is `not (min-width: 28rem)`), and `cq={{ 'sidebar/md': { … } }}` addresses a container by name. Ranked after every breakpoint and before every preference; one at-rule block per rule, so `cq` and a breakpoint do not nest inside each other

20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too

21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties

22. **Nine filters stack too, and a mask is a gradient.** `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia` and `dropShadow` each set their own layer of one composed `filter`, so a blur and a desaturation coexist. **A number is the function's own unit** — `brightness={110}` is a percentage, `hueRotate={90}` degrees, `blur={3}` pixels — and `blur` takes Tailwind's scale as well (`xs` 4px … `xxxl` 64px). `dropShadow` (`xs`…`xxl`, with `dropShadowColor`) is cast by the **shape**, where `shadow` draws a rectangle; `none` clears one function. The nine `backdrop*` props are the same functions behind the element — `backdropBlur="sm" backdropSaturate={180}` over something translucent is glassmorphism — and they and the older `backdropFilter` are the same property, so use one. **`maskImage` takes the `bgGradient` record** and reads its alpha: `maskImage={{ linear: 'b', colors: ['black', 'transparent'] }}` fades an edge, `url(#id)` masks by a shape. **`bgClip="text"` needs `color="transparent"`** beside it, and together they are how a gradient becomes type

23. **An alignment can be overflow-safe, and a ratio is a value.** The box-alignment set is complete: `jc`/`ai` plus `justifyItems`/`placeItems`/`placeContent`/`alignContent`/`alignSelf`/`justifySelf`, and every one of them takes `'safe center'`, `'safe start'`, `'safe end'` and the `'unsafe …'` twins — `safe center` centres until the content stops fitting and then aligns to `start`, where plain `center` overflows both edges and the half above the scrollable origin cannot be reached. `aspectRatio` is `'square'` (1/1), `'video'` (16/9), a compact ratio (`'4/3'`) or a number; `'4:3'` emits nothing. `insetX`/`insetY` are `inset-inline`/`inset-block`, the way `mx`/`my` are the margin shorthands, and every inset prop now takes `'auto'` and the fractions (`inset="1/4"`, `top="-1/3"`). **The parts of a native control the page does not draw are props too**: `accentColor` (a checkbox tick, a radio dot, a range track — it inherits, so a form sets it once), `caretColor`, `colorScheme` (`'light dark'` — what scrollbars and form controls follow), `fieldSizing="content"` (a field that grows as it is typed into), `scrollbarGutter="stable"` (the scrollbar's space reserved before there is one) and `willChange`, which costs memory: put it on the few elements that move, never on a list.

24. **Somebody else's state is `group` or `peer`.** `group={{ 'card/hover': { opacity: 1 } }}` → `.card:hover .className` (an ancestor), `peer={{ 'agree/checked': { color: 'emerald-500' } }}` → `.agree:checked ~ .className` (a preceding sibling); the ancestor or sibling carries the class through `className`, and a bare key uses Tailwind's default names (`group`, `peer`). The state half is the vocabulary `not` takes — any pseudo-class, or a `data-`/`aria-` prefix for an attribute the ancestor carries (`'row/data-state=open'`). **The element's own states stay on the element**: `group={{ 'card/hover': { hover: … } }}` is `.card:hover .x:hover`. `hoverGroup`/`focusGroup`/`activeGroup`/`disabledGroup`/`selectedGroup` are the older spelling of the same rule and share its class. **Eight more browser states are pseudo-class props**: `open` (an `[open]` element, a popover, a `<select>`'s picker), `placeholderShown`, `autofill`, `inRange`/`outOfRange`, `target`, `inert` (the whole inert subtree) and `visited`, which takes **colour properties only** — the browser refuses the rest so a page cannot read a reader's history. **`pointerCoarse`/`pointerFine`** are two more media keys beside the preferences, ranked below them: a finger needs a bigger target and never hovers.

25. **A side is logical, and the direction is a state.** `ps`/`pe`, `ms`/`me`, `bs`/`be`, `insetStart`/`insetEnd` and `borderRadiusStart`/`borderRadiusEnd` (plus the four logical corners, **block axis first**: `borderRadiusStartStart`…) are the two sides of the inline axis the `px`/`mx`/`insetX` pairs already work on — start is left in a left-to-right reading and right in a right-to-left one, resolved by the browser from `dir`, so a translation needs no second stylesheet and no re-render. Same dividers as the physical twins (÷4, and direct px for `bs`/`be`); `textAlign` takes `'start'`/`'end'` beside `'left'`/`'right'`. For what a logical property cannot express — an arrow that must point the other way — `rtl={{ flip: 'xAxis' }}` and `ltr={{ … }}` are ordinary pseudo-class keys compiling to `:dir(rtl)`/`:dir(ltr)`, **not** Tailwind's `[dir="rtl"] &`: the direction belongs to _this_ element, so a `<bdi>` or a `dir="auto"` is seen. With no `dir` anywhere the document is left-to-right, so `ltr` matches — it is a state, not an attribute you have to write, and the `dir` attribute itself goes in `props`. **The pre-built components come with it**: the arrow keys follow the reading order (`ArrowLeft` is the _next_ item in a right-to-left list, APG's rule), a DataGrid column pins to `'START'`/`'END'` of the inline axis (`'LEFT'`/`'RIGHT'` still mean those two) and `align` takes `'start'`/`'end'`, and a popup keeps the direction it was declared in, by staying in that subtree (rule 30) or, on the portal fallback, by carrying it across. `Overlay`'s page coordinates stay physical on purpose — a measured pixel has no reading order.

26. **The 5% is `css`, and it is still a class.** `css={{ mixBlendMode: 'multiply', WebkitLineClamp: 2 }}` takes a style object for the properties this library has no prop for and compiles it through the same pipeline as every other prop — one shared class, nested anywhere a prop nests (`hover={{ css: … }}`, `md`, `theme`, `dataAttr`, `before`, `startingStyle`, a keyframes step), rendered on a server — so it is never `style={{ }}`. Names are camelCase and typed by csstype (a misspelt property is a compile error, `WebkitLineClamp` → `-webkit-line-clamp`); values are CSS written as they stand, a number included, so a length wants its unit (`width: '100px'`, the type refuses `100`) and a colour token resolves the way a `vars` value does (`outlineColor: 'sky-500'`). It is sorted **last**, so on one element it wins the property a typed prop also names. A value carrying `;` or a brace is dropped, that entry only. Reach for a typed prop first, `Box.extend()` for anything used twice, `css` for the one-off.

27. **A floating layer is anchored in CSS, not measured in JS.** `anchorName="trigger"` names the anchor (the `--` is optional and added for you), and on the layer `position="absolute"` + `positionAnchor="trigger"` + `positionArea="block-end span-all"` place it — `positionArea` is which cell of the 3×3 grid around the anchor to sit in, **block axis first, inline axis second**, two keywords from one family (`'block-end center'`, `'top left'`; `'top inline-start'` mixes families and emits nothing). Offset it with an ordinary margin. `positionTryFallbacks="flip-block"` is the flip (`flip-inline`, `flip-start`, space-combined into one candidate, comma-separated into a list tried in order), `positionTryOrder` takes the roomiest instead of the first that fits, and `positionVisibility="anchors-visible"` hides the layer once its anchor scrolls away. `justifySelf`/`alignSelf` take `'anchor-center'`. **Two traps, both measured**: a candidate has to fit on _both_ axes, so `positionArea="block-end center"` with a layer wider than its anchor disqualifies every position and `flip-block` does nothing — span the axis you keep (`'block-end span-all'`); and `positionVisibility` hides at paint time, so computed `visibility` still reads `visible`. Chrome 125+/Firefox 147+/Safari 26+; elsewhere the layer renders unpositioned, so the portable path is `useAnchorPosition` (rule 28), which measures instead.

28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.

29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus into the panel on open (`autoFocus`), and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.

30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.

31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.

32. **A menu button is `<Menu>`, and a submenu is a popover nested in it.** `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>` (`components/menu`) is APG's menu button on the Popover API: `role="menu"`, `menuitem`/`menuitemcheckbox`/`menuitemradio` with `aria-checked`, `role="group"` around a titled section and `role="separator"` between them. The parts are `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group` (a `label` names the group, so its items are read as a set), `Menu.Separator` and `Menu.Sub`, which renders its own item — label, chevron, `aria-haspopup="menu"`, `aria-expanded` — and the menu beside it. **The platform's half is the top layer, light dismiss and the toggle**: Escape closes the innermost menu first, one layer per press, and a press outside closes the lot, with no portal and no `z-index` anywhere. **What is written here is the keyboard and the ARIA**: Down/Up wrap, Home/End, typeahead (a letter again cycles through the items starting with it), Right opens a submenu and Left closes it — the _reading order_, so the two swap in a right-to-left menu and the chevron turns round with them. **Four things to know.** A `disabled` item is `aria-disabled` and **stays focusable**, which is APG's rule: the `disabled` attribute would take it out of the keyboard's reach and silence it. **A command closes the menu and a state does not** — `Menu.Item` closes on select, `Menu.CheckboxItem` and `Menu.RadioItem` do not, so several boxes can be ticked in one visit, and `closeOnSelect` swaps either default. `onOpenChange(open, { reason })` adds **`select`** (an item was chosen — the transition worth telling apart) and **`tab`** to the four a layer always reports. And the menu is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>`, the items render whether or not it has been opened, and the top layer's one cost applies to it as it does to `<Popover>` (rule 30): a menu keeps the side it opened on. Placement is `side`/`align`/`offset`/`flip` on both the menu and each `Menu.Sub`, whose defaults are `side="end"` and `offset={0}` so a submenu abuts the menu it came out of. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in one place. The semantic `<menu>` element is **`MenuList`** from `components/semantics` now — `Menu` there is deprecated, because two exports of that name are one import away from the wrong component.

33. **Tabs are `<Tabs>`, and selection follows focus.** `<Tabs defaultValue="overview"><Tabs.List label="Project"><Tabs.Tab value="overview">Overview</Tabs.Tab></Tabs.List><Tabs.Panel value="overview">…</Tabs.Panel></Tabs>` (`components/tabs`) is APG's tabs pattern in four parts: `role="tablist"` named by the list's own `label`, `role="tab"` with `aria-selected` and `aria-controls`, and `role="tabpanel"` named by its tab and carrying `tabindex="0"` so its content is reachable. **One arrow key moves and selects** — APG's default; `activation="manual"` splits the two, so the arrows move and Enter or Space chooses, which is what a panel too expensive to render on the way past needs. **The keyboard is one tab stop**: Tab enters the list once, landing on the selected tab, and again leaves it for the panel, however many tabs it holds. Right/Left in a horizontal list, Down/Up in a vertical one, both wrapping unless `loop={false}`, Home/End to the ends, and the **off-axis pair is left to the page**, so a horizontal list does not eat a scroll. The arrows follow the **reading order**, so in a right-to-left page ArrowLeft is the _next_ tab, and a vertical list puts its indicator on the inline end (`be`, not `br`). **Four things to know.** A `disabled` tab is **not** selectable and the arrows step over it — the opposite of `Menu.Item` (rule 32), because selection follows focus here and a tab focus could reach but selection could not would leave the widget with no state to be in. **Only the selected panel is rendered**, so an unmounted panel costs nothing and gets an entrance from `startingStyle` for free but loses whatever state it held: `keepMounted` renders them all and hides the rest, the `hidden` attribute plus a `display: none` of its own, since every Box carries `display: block` and outranks the UA's `[hidden]` rule. **The tabs are read off the DOM**, not out of a registry, so a tab wrapped in a layout of your own, rendered from a list or put behind a condition navigates like any other, and a nested set of tabs belongs to its own list. And **the tab sequence follows the selection** — a controlled `value` changed elsewhere on the page moves the keyboard's entry point with it — except under `activation="manual"`, where it follows the focus, as APG's own example does. `onValueChange(value, { reason })` reports `'click'` or `'keyboard'`. **Two things move, and both are opt-in.** `indicator="sliding"` swaps each tab's own border for one element for the whole list, which animates between tabs because it _is_ the same element; wrapping the panels in a **`Tabs.Panels`** gives that container the height of the panel on screen, so panels of different heights transition instead of jumping. Both are measured, with the consequences that follow from that: the travelling indicator appears only once the widget has run, so until then the tabs keep drawing their own border — which is what a prerendered page paints, in the same place, so the handover is invisible; the container watches the **panel** and never itself, since its own height is what it writes; and both ride `--transitionTime`, so `prefers-reduced-motion` computes each to `0s`. The container is clipped **only while its height is travelling**, because at rest it is exactly as tall as its panel and a permanent clip would cut the focus ring off anything sitting at that edge — and `overflow` would clip both axes whatever was asked for, computing a `visible` companion to a clipped axis up to `auto`. Styling is the `tabs`/`tabs.list`/`tabs.tab`/`tabs.panel` tree plus `tabs.indicator` and `tabs.panels`, where the selected state is the bare `selected` key (it is `aria-selected`); **on a tab it is `ariaAttr={{ selected: … }}`**, because a top-level `selected` _prop_ writes that attribute rather than nesting styles under it — the pseudo2 rule every one of `selected`/`disabled`/`checked` follows. The indicator is a border rather than a background, because a forced-colors mode keeps one and throws the other away.

34. **An accordion is `<Accordion>`, and its height animation is a class.** `<Accordion defaultValue={['shipping']}><Accordion.Item value="shipping"><Accordion.Trigger>Shipping</Accordion.Trigger><Accordion.Panel>Two to four days.</Accordion.Panel></Accordion.Item></Accordion>` (`components/accordion`) is APG's accordion in three parts, and **`Collapsible` is a named export of the same module** — one disclosure with a render-prop `trigger` and no heading, the way `<Popover>` reads. **Nothing is measured.** The panel sits in a grid of one row whose track runs `1fr` to `0fr`, so there is no `ResizeObserver`, no measured pixel and no custom property per instance: two shared rules animate every accordion on the page, and content that grows while a panel is open grows with it. **A closed panel is hidden with `visibility`, and is always rendered** — content behind a zero-height track is still laid out, still tabbable and still read out, and `visibility` is the one hiding mechanism that is _animatable_, flipping to hidden only once the track has closed and back the instant it opens. That is why there is no `@starting-style` and no `transitionBehavior="allow-discrete"` here, and why a server-rendered open panel does not animate itself open on load; it also means a half-filled form in a panel survives being shut, and expensive children are yours to gate with `{open ? … : null}`. **Four things to know.** **Every header is its own tab stop** — the opposite of `Tabs` (rule 33), because an accordion is not a composite widget: Down/Up (wrapping unless `loop={false}`) and Home/End are a shortcut between headers, and the sideways pair is left to the page. Each header is a real `<button>` inside a heading whose level is **`level`** (default `3`), since a heading level is the document's outline; the panel is a `role="region"` named by it, and `props={{ role: undefined }}` drops the landmark past the half-dozen panels APG's own caveat warns about. **One panel at a time unless `multiple`**, and closing the open one is always allowed, so there is no second prop for whether the accordion may stand empty — `value`/`defaultValue` are a `string[]` either way. A `disabled` item is the **`disabled` attribute** and the arrows step over it, as on a tab. Styling is the `accordion`/`accordion.item`/`accordion.heading`/`accordion.trigger`/`accordion.arrow`/`accordion.panel` tree plus `collapsible`, and a header's open state is its own `aria-expanded`, so `ariaAttr={{ expanded: … }}` styles it with no variant. The two parts that are not yours are **`accordion.track`**, the grid whose row animates, and **`accordion.clip`** inside it, the bare item that does the clipping. Bare is the point: **padding cannot be squeezed**, so a grid item carrying any floors the `0fr` track at exactly that much — which is why the panel you pad sits _inside_ the clip rather than being it (measured, #142).

35. **A slider is `<Slider>`, a bar is `<Progress>`, and a number in is a number out.** `<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />` (`components/slider`) is APG's slider, and `defaultValue={[20, 80]}` with `thumbLabels={['Lowest', 'Highest']}` is the multi-thumb one — **the value's own shape is how many thumbs there are**, so there is no second prop saying which kind this is and no narrowing at the call site: a `number` in means a `number` back, an array means an array. A thumb may meet its neighbour and never pass it. `<Progress label="Upload" value={62} />` (`components/progress`) is the `role="progressbar"` beside it, and **it renders on a server** — no state, no effect, no measurement. **Neither is the native element, and that is the one place the platform loses**: `<input type="range">` holds a single thumb, and it and `<progress>` both draw themselves with vendor pseudo-elements (`::-webkit-slider-thumb`) that no typed prop can reach — so `name` on a `Slider` is what keeps the form working, writing one hidden input per thumb. **Four things to know.** **The position is an inline style, and it is the only one**: a thumb's offset is per _frame_ of a drag, so a class for it would be a rule per frame that is never freed — the exception `useAnchorPosition`'s anchor name and the travelling tab indicator already take, and the reason a `ProgressRing` (`components/chart`) can round its fraction into a class instead, since nobody drags a ring. **No `value` on a `Progress` is a state, not a zero**: `aria-valuenow` is omitted rather than reported as `0`, and the bar sweeps — a named duration, so it stops itself under `prefers-reduced-motion`. **A `role="slider"` has to be named**: `label` names the thumb on a single slider and the `role="group"` on a range, where `thumbLabels` names the thumbs one at a time, and `format` writes `aria-valuetext` for a value a bare number does not read as. And **it mirrors for free** — the fill and the thumbs are placed with `inset-inline-start`, so a right-to-left page draws the minimum on the right with nothing declared twice; only the sideways arrows swap (Left is the increase), and Up/Down never do, because the block axis has no reading order. `onValueChange` fires on every step and `onValueCommit` once at the end, both with `{ reason: 'pointer' | 'keyboard' }`. **A press travels and a nudge does not**: a press on the track is the one move the eye has to follow, so the thumb animates the whole way, while every other move — a drag, an arrow key, a held arrow — takes a short 60ms **`ease-out`** travel on both the thumb and the fill, which is the `tracking` variant and which both parts must carry or they come apart. Short rather than _off_, because off is exact and **steps**: a value on a grid can only be at its grid positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. Eased **out** rather than linear or eased, because a repeat restarts the transition every 33ms so only its first half is ever seen — on this curve that half is the straight part, so it glides while the key is held and still decelerates on the last transition, the only one allowed to finish; plain `ease` replays its slow-in and pulses, and `linear` rides more than a full step behind the value and stops at full speed. And an arrow key is a nudge rather than a jump, so it never takes the press travel at all — one tap spending 250ms on 3.2px was itself the lag. `step` is the real dial — `step={0}` is continuous and needs no interpolation at all. Styling is the `slider`/`slider.track`/`slider.fill`/`slider.thumb` tree and `progress`/`progress.fill`, whose `indeterminate` variant is the sweep; `disabled` is `aria-disabled` here rather than the attribute, since a div takes none and a value nobody can reach is a value nobody can read.

36. **A message you send rather than render is `<Toaster>` plus `toast()`.** `<Toaster />` once near the root of the app (`components/toaster`), and after that `toast.success('Saved')` from anywhere at all — an event handler, a fetch, a router guard, a module with no React in it. The store behind it is framework-free, so a call made **before** the viewport mounts is queued rather than lost, and the component only draws what is there. The surface is `toast(message, options)` with `success`/`error`/`warning`/`info`/`loading` beside it, `toast.promise(p, { loading, success, error })` (one toast for the whole call, and the promise comes back untouched), `toast.update(id, message, options)` and `toast.dismiss(id?)`; an `options.id` already on screen is an **update**, which is what makes a promise one toast rather than two. A toast takes a `description`, one `action` (`{ label, onClick }`, which dismisses the toast it answered unless `closeOnClick: false`), a `duration` in milliseconds (`Infinity` waits, and `loading` already does), and `onDismiss(reason)` with `timeout`/`close`/`action`/`imperative`. **The limit is a queue, not a cap**: past `limit` (default 3) a toast waits **with its timer unstarted**, so nothing expires that was never on screen, and a counter at the far end of the stack says how many are waiting. **Four things to know, all measured in Chrome 153.** The viewport is a **polite live region that exists before there is anything in it** — a region inserted together with its content is not reliably announced — and an error toast is `role="alert"`, the one announcement pattern every screen reader implements; nothing else carries a region of its own, because the nearest region to a change is the one that speaks. It is in the **top layer** with no portal (`popover="manual"`, shown as it mounts), so it inherits the theme and the direction around it, and it takes **no pointer events at all** while the toasts take them back — a corner-sized fixed strip would otherwise swallow every press in its gaps. **Timers stop on hover, on focus and in a background tab** and pick up where they left off, which is WCAG 2.2.1 and the only reason a toast may carry a control at all; a toast **never takes focus**, so `F6` (or `hotkey`, which takes `'alt+t'` or `false`) is what moves focus to the stack, Tab walks it, and Escape dismisses the toast focus is in and hands focus back once the stack is empty. And the viewport has to answer the UA's own `[popover]` rule: `inset: 0` leaves a corner-pinned stack over-constrained — `top`/`left` win and it sits in the wrong corner — and `overflow: auto` makes it a scroll container, so the style tree declares all four sides `auto` and `overflow: visible` before a `position` variant names the two it wants. `position` is `'bottom-end'` by default and its inline half is logical (`start`/`end`), the newest toast is always nearest the screen edge, and styling is the `toaster`/`toaster.toast`/`toaster.message`/`toaster.description`/`toaster.action`/`toaster.close`/`toaster.overflow` tree, where the kind is a variant on the toast.

37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox`, everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the four (`text`/`number`/`checkbox`/`select`) or configures it (`{ type, options?, placeholder?, step?, min?, max? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.

38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.

39. **The block goes back in with Ctrl+V, one judgement per cell.** A paste fills from the current cell — or from the block that is marked, where one is — and every cell it covers goes through the same `def.onCellEdit` an editor would, so there is no second validator, no second event and no second way of writing a value; accepted cells land in the same stream a typed one does, with a `reason` of `'paste'`. It needs no opt-in, for the reason Ctrl+C does not: `def.rangeSelection` only gives it a bigger target. **Each axis takes whichever is longer, the block or the clipboard** — a block bigger than the clipboard is tiled with it, a clipboard bigger than the block spills past it, and both stop at the edge of the grid; one rule, with the degenerate case falling out of it. **Four things to know.** **A refusal skips its own cell and nothing else**, because a paste is many independent judgements and stopping at the first bad value would leave the block half written with no way back; the refused cells wear the red ring (`datagrid.body.cell`'s `isInvalid`, with no editor open behind it) and report `aria-invalid`, so the cells say which ones and **`onPaste({ range, applied, rejected, skipped })`** says why — once for the whole block, after the values have landed, so `range.values()` reads what the grid now shows. **The clipboard carries no types, so the cell says how to read the text**: a number column parses it and refuses what is not a number, a checkbox takes `true`/`false` (and `1`/`0`), and a `select` keeps to its own options and holds the option's value rather than its spelling. **A value that did not change is not an edit at all**, so pasting a column back over itself — the way a spreadsheet is used — costs nothing and reports nothing. And **a paste that reaches an open editor belongs to the editor**: that is a value being typed, not a block being filled, so the grid hands it back rather than filling the cells under it; a cell nothing can be written to is skipped before anything is asked about it, and counted.

40. **A UI a model writes at runtime is `catalog()` and `<SpecRenderer>`, and both ends of it are the app's.** `catalog({ include, exclude, styleProps })` (`@box-kite/react/catalog`) is every component and every value its props take, as JSON Schema — read off the live prop registry, so a `Box.extend()` prop or colour is in it with nothing regenerated — and `specSchema(registry)` turns that into the one schema a model generates a whole _tree_ under, for `streamObject`, a structured-output API or `z.fromJSONSchema` — exported from **both** entries, and in a route handler it is `@box-kite/react/catalog`'s you want, since `/spec` renders and carries a `use client` banner; it takes a `catalog()` as readily as a registry, so the server half needs no components. `<SpecRenderer spec registry data onAction />` renders what came back, and `createSpecRegistry({ catalog, components: { Flex, H2, Sparkline } })` is the allow-list: a name it does not hold renders nothing at all. **A node is `{ type, props, children, slots, on, repeat }`, and every field of it is checked** — a prop the component's own schema refuses is dropped (the prop, not the node), the only prop that can ever become a function is one the catalog lists as an `event` (`on: { onClick: 'refresh' }` calls `onAction('refresh', details)`, and what that means is yours), and there is no tag from the spec, no `eval` and no `dangerouslySetInnerHTML` anywhere in it. **Four things to know.** A spec **arrives in pieces**, so a node whose `type` has not been written yet renders nothing _and reports nothing_, while a value that is still half a string fails its schema and is dropped until it is whole — and a node still missing a prop its component cannot do without is held back with a `missing-prop`, since a required prop that is absent would be `undefined` inside the component. **A generated node writes the controlled prop and never the `default…` twin** (`layout`, not `defaultLayout`; `value`, not `defaultValue`): an uncontrolled default is read once, so the frame it first arrived whole in is the one that sticks, and a second spec rendered in the same place keeps the first one's state. A stream is not monotone either — a column's `align` passes through `"e"` on its way to `"end"`, taking the whole `def` with it — so `<SpecRenderer>` keeps the last value each node was given for a prop it cannot render without, and a heavy component is the app's to gate: point its name at a placeholder in the registry while the spec is arriving. Every node has an **error boundary of its own**, so a component that throws on props a model invented costs that node and nothing around it. `{ $data: 'stats.revenue' }`, `{ $item: 'label' }` and `{ $index: true }` read from the `data` prop **before** the schema judges them, so what the host supplied is what is validated — a path, never an expression. And everything refused is **reported** rather than swallowed: `onIssues` gets a code (`unknown-component`, `invalid-prop`, `unknown-event`…) and the path it happened at. **The props that are a shape are described by hand**, because the other half of each of them is React: `DataGrid`'s `def` (required — the columns, plus `rowKey`/`title`/`footer`/`groupBy` and the rest of the flags) and `data` (the rows, the one prop in the catalog carrying values rather than styling — `{ $data: 'orders' }` is the usual answer), `DashboardGrid`'s `layout`/`defaultLayout` (`DashboardUtils.SCHEMA`, the artifact a drag reports back) and `columns`, `Widget`'s `empty`, `ChartContainer`'s `series`. What a spec cannot write stays out: a column's own nested `columns` is a recursive schema, and `dataSource`/`onCellEdit`/`rowDetail`/`treeData` are the app's to pass beside the spec. `maxNodes` (1,000) and `maxDepth` (32) end a runaway tree, `fallback` is what stands where a node could not render (nothing, unless you say otherwise), and `renderSpec()` is the same walk with no hook in it, for a server or a test.

41. **A dashboard people rearrange is `<DashboardGrid>` plus `<Widget>`, and its layout is JSON in cells.** `<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable><Widget id="revenue" title="Revenue"><Sparkline data={revenue} width="100%" height="100%" /></Widget></DashboardGrid>` (`components/dashboard`): the layout is `{ version, columns, items: [{ id, x, y, w, h, minW?, minH?, maxW?, maxH?, fixed? }] }` and a `Widget` fills the item carrying its `id` — one with no item is left to the grid's own flow, and outside a grid a `Widget` is a card. **Nothing is measured to lay it out**: a place is `gridColumnStart`/`gridColumnEnd`/`gridRowStart`/`gridRowEnd`, which are props, so it is a shared class and the grid renders on a server; the one inline style is the translate that keeps a dragged widget under the pointer (the `Slider` exception, for the same reason). The layout is **compacted upward**, so a widget cannot be parked in mid-air and two dashboards of the same widgets in the same places compare equal — and a widget dropped on a neighbour **takes the cell**, the neighbour being handed the row above where there is room for it and the row below otherwise, since compaction on its own reads the layout in reading order and would simply undo the drop. **`columns` is a count per container size** (`{ xs: 1, md: 6, xxl: 12 }` by default, the `cq` scale), and every narrower arrangement is that one layout _projected_ — arithmetic at render, written as a container query, so the browser picks between classes. Two traps behind that, both measured: **a grid cannot container-query itself** (the query resolves against an ancestor container, so a track count per size silently does nothing — which is why every projection is drawn on the widest arrangement's tracks), and **an arrangement can only be edited in the space it is written in**, so where a projection is showing the handles are not rendered at all. **Four things to know.** `editable` is the whole edit/view split — without it there are no handles and nothing in the tab order — and both handles are real buttons: **dragging is not a keyboard gesture, so the keyboard gets a grab**, Enter or Space to pick the widget up, the arrows to move or resize it a cell at a time (the _reading order_, so ArrowLeft moves it right in a right-to-left page), Enter to drop and Escape to put it back, every step announced in a live region that was there first. `onLayoutChange` fires on every cell a drag crosses and `onLayoutCommit` once at the end — the one to write to a server — both with a reason of `'move'` or `'resize'`, the device being in `details.event`. A `Widget` is chrome and **four states**: `loading` (bars, `aria-busy`), `error` (the message, plus a retry where there is an `onRefresh`), `empty`, and otherwise its children; its `title` is a real heading at `level` (default 3), and `name` is what the handles and the announcements call it. And **the layout is the artifact, so it is also the prompt**: `DashboardUtils.SCHEMA` is it as JSON Schema — the subset `catalog()` emits — and `DashboardUtils.parse(value)` reads one back from a database or a model, clamping what is out of range and reporting what it dropped rather than throwing. Where it is kept is the app's. Styling is the `dashboard`/`dashboard.placeholder` and `widget`/`widget.header`/`widget.label`/`widget.title`/`widget.description`/`widget.actions`/`widget.handle`/`widget.body`/`widget.message`/`widget.retry`/`widget.skeleton` tree; both handles are the one `handle` node, whose `corner` variant is the resize one, and the row height is a variable (`vars={{ 'dashboard-row': '8rem' }}`) rather than a prop.

42. **A theme is an ancestor, and the nearest one owns the subtree.** `theme={{ dark: { bgColor: 'slate-900' } }}` nests on any element and takes everything else with it (`theme={{ dark: { hover: … } }}`, `md={{ theme: … }}`); `<Box.Theme use="global">` puts the theme class and `data-theme` on `<html>`, `use="local"` on a wrapper of its own, and `Box.useTheme()` reads and sets it. The name is any identifier, not just `light`/`dark`. **A theme inside a theme is a real boundary**: each rule is `@scope (.dark) to ([data-theme]) { :scope .className … }`, so a local `<Box.Theme theme="light">` inside a dark page is light throughout — including for a prop the inner theme never mentions, and including where the outer theme's rule carries one more pseudo-class. What ends a theme is **`data-theme`**, so a theme class written by hand (the one a prerendered shell puts on `<html>`) wants the attribute beside it; `<Box.Theme>` renders both. A theme costs no re-render — it is one class swapping on an ancestor — and it needs Chrome 118+, Safari 17.4+, Firefox 128+, below which theme rules are dropped and the unthemed values show.

43. **An agent's turn is `<ToolCallCard>`, `<ApprovalCard>` and `<Reasoning>`.** `<ToolCallCard name="searchOrders" status="running" input={{ query: 'refunds' }} />`, `<ApprovalCard title="Refund order 4182" onDecisionChange={(decision) => respond(decision === 'approved')} />` and `<Reasoning duration={4200}>{thought}</Reasoning>` (`components/agent`) are the three parts of a turn that are not prose: what it ran, what it wants permission to do, and what it was thinking. **The status is a word, not a colour** — `pending`/`running`/`success`/`error` each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had it — and the four map one for one onto what every runtime reports under its own spelling, so AI SDK's `input-streaming`/`input-available`/`output-available`/`output-error` is a lookup rather than a state machine. **A value is whatever the model produced, so it is formatted rather than trusted**: `AgentUtils.formatValue` (framework-free, same entry) caps the text at `valueLimit` — 20,000 characters, with a line saying how much was left — and answers a circular structure, a `BigInt` and a function, all three of which make `JSON.stringify` throw or drop them silently. **Four things to know.** `onDecisionChange(decision, { reason })` is the approval card's whole API, which is what maps it onto AI SDK 6's `needsApproval`, CopilotKit's `renderAndWaitForResponse` and AG-UI's `INTERRUPT`; the answer lands in a `role="status"` **that is in the DOM before there is anything in it**, the toaster's rule, and `busy` is the round trip to a server. The card **does not take focus** unless `autoFocus` says so, and then on _Reject_ — APG's least-destructive rule — because a turn arrives while the reader is somewhere else. A `ToolCallCard` with nothing to disclose **renders no control at all**, the header being a row of text rather than a button nobody wants to land on. And `Reasoning` is **closed by default** and opens in the same `1fr`-to-`0fr` grid an `Accordion` panel does, so nothing is measured: `streaming` only changes the words and the shimmer, and `open={streaming}` is how an app makes it follow. Styling is the `toolCall`/`approval`/`reasoning` trees, where the status is a variant on `toolCall.status`, the decision one on `approval` itself, and `toolCall.header`'s `interactive` variant is what makes a header look pressable.

44. **What it _says_ is `<StreamingText>`, `markdownComponents` and `<Skeleton>`.** `<StreamingText text={message} streaming />` (`components/agent`) takes the text so far and fades in the part that was not there a render ago — **hand it the whole message every time, not the delta**, because knowing what is new means comparing the two. What is on the page is one settled string plus the last few runs to arrive (`window`, 8 by default; `0` turns the entrance off), so a message already whole paints at once with nothing moving — a prerendered page, a transcript read back — and one still arriving costs the same at the ten-thousandth token as at the first. The entrance is `@starting-style` rather than a keyframe, so it rides `--transitionTime` and stops under `prefers-reduced-motion` on its own, and the element is deliberately **not** a live region: one announcing every token would read the message out a word at a time and again when it finished, so it carries `aria-busy` and the transcript does the announcing. **The markdown bridge is a map, not a component**: `markdownComponents` (`components/markdown`) is the `components` map `react-markdown`, Streamdown and everything on that shape already take, so the parser stays the app's and there is no stylesheet, no Tailwind `@source` line and no design tokens to declare — wrap the renderer in `<Box component="markdown">` and the nodes inherit its size and colour. It is a **constant**, and that matters while streaming: a map built inside render is a new set of component _types_ every token, which React answers by unmounting the whole message, so override by spreading at module scope (`{ ...markdownComponents, h1: MyHeading }`). Whether a URL is safe stays the renderer's `urlTransform`, since the href is parsed before a component is called. **`<Skeleton lines={3} />`** (`components/skeleton`) is the placeholder — `circle` for an avatar, the gloss a named duration so it stops itself — and it is `aria-hidden` unless given a `label`, which makes it one `role="status"`: put that on the skeleton standing for a region, never on every bar. Styling is `streamingText` (`segment`, `caret`), `skeleton` (`bar` with `short`/`circle`, `gloss`) and `markdown` (`heading`, `paragraph`, `link`, `list`, `item`, `quote`, `code`, `codeBlock`, `rule`, `image`, `table`, `row`, `cell`, `inline`, `checkbox`).

45. **A runtime's words for a tool call are `toolPart`, and A2UI is `a2uiToSpec`.** `@box-kite/react/interop` is this library's shapes and the agentic ecosystem's, mapped onto each other, and it **imports none of them** — an adapter that pulled in a runtime would choose one for the app. `toolPart(part)` reads a part from AI SDK, assistant-ui or CopilotKit and answers `{ kind, status, decision, input, output, error }`, where `kind` is `'call'` or `'approval'` and `status` is what `<ToolCallCard>` takes: **a decision is not a stage a call passes through, it is a question somebody answers**, which is why two components cover AI SDK's six states (`input-streaming`/`input-available`/`output-available`/`output-error` are a call, `approval-requested`/`approval-responded` are a question), assistant-ui's `requires-action` and the `executing` CopilotKit hands a `respond` with. **AG-UI reports _events_, not parts**, so it folds instead: `applyToolEvent(parts, event)`, one call per event, keyed by `toolCallId`. **A2UI is the only genuinely different shape**, a flat adjacency list of components referring to each other by id with a data model per surface, so it has real code: `a2uiApply(state, message)` folds a v0.8 or v0.9 stream (pure, and the same state back when nothing moved), `a2uiSurface(state)` picks one out and `a2uiToSpec(surface, { catalog })` walks it into the tree `<SpecRenderer>` renders — its `{ "path": "/x" }` binding is already `{ $data: '/x' }`, its `children: { componentId, path }` template is already `repeat`, and a component nothing reaches from the root is simply not arrived yet. `a2uiCatalog(catalog())` is the other direction, `catalog()` as an A2UI catalog document, where a component carries its own `id`, its type is a property and its children are **ids**. assistant-ui's `GenerativeUISpec` is `fromGenerativeUi`/`toGenerativeUi`, and the second **reports what it could not carry** (`data-binding`, `repeat`, `event`), because their nodes have nowhere to put those three. One trap, measured against zod 4.6: **a `$ref` resolves against the document, not the piece you lifted out of it**, so `z.fromJSONSchema(document.components.Flex)` throws `Reference not found: #/$defs/color` — `a2uiComponentSchema(document, name)` is that component with the definitions attached.

46. **A scroll-driven animation is a timeline prop, and it costs no JavaScript at all.** `animationTimeline="scroll()"` runs an animation off the nearest scrollport's progress and `"view()"` off this element's own pass across it — no listener, no rAF, no state, and the browser drives it on the compositor. Where the scroller is not an ancestor, name one: `scrollTimeline="page block"` on the scroller (or `viewTimeline="card block"` on the subject), `animationTimeline="page"` on the animated element, and `timelineScope="page"` on their common ancestor when the two are not nested — the `--` on a name is optional and added for you, the axis is `block`/`inline`/`x`/`y` (logical, so it mirrors), and the longhands `scrollTimelineName`/`Axis` and `viewTimelineName`/`Axis` exist to override half a shorthand. **`animationRange` is which part of the pass the animation occupies**: `cover`, `contain`, `entry`, `exit`, `entry-crossing`, `exit-crossing`, each taking an offset — `animationRange="entry 0% entry 50%"`, or the `animationRangeStart`/`animationRangeEnd` pair — and `viewTimelineInset` shrinks the scrollport the pass is measured against, which is how a reveal fires before the element really reaches the edge. **Three traps.** The CSS `animation` shorthand **resets `animation-timeline`**, so a timeline declared before it is silently undone; the registry's order settles that for the props, but not for an `animation` written inside `css`. A scroll-driven animation has **no duration**, so `--transitionTime` cannot zero it and it is the one motion here that does _not_ stop itself under `prefers-reduced-motion` — `motionReduce={{ animation: 'none' }}` is the opt-in, and it is not optional. And where the browser has none (Safari, and Firefox behind a flag) the declaration is dropped rather than the animation: it runs on the **document timeline** over whatever duration it was given, so make the end state the resting state and a missing timeline degrades to "already arrived" instead of a loop.

47. **A view transition is two props and one call.** `Box.viewTransition(() => applyChange())` runs a DOM change inside `document.startViewTransition` where the browser has one and plainly where it has not — the same `ready`/`finished`/`updateCallbackDone` promises and `skip()` either way, so a caller has one code path rather than two — and `viewTransitionName="header"` is what makes an element animate _from where it was to where it is_ instead of being cross-faded along with the page. `viewTransitionClass="card"` groups several names, so `::view-transition-group(.card)` styles them at once. **In React the update has to be flushed**: the browser screenshots the page the moment the callback returns and a `setState` has not rendered by then, so a hand-rolled version captures the old state twice and nothing appears to move — write `Box.viewTransition(() => flushSync(() => setTheme('dark')))`, or use **`<Box.Theme viewTransition>`**, which is that call for the one change every app has. **A name has to be unique in the document while the transition runs**, so a list wants a name per item — and a class per item is a rule this library would never free, which is why that one goes in `props={{ style: { viewTransitionName: id } }}`, the exception `useAnchorPosition`'s anchor name and a `Slider` thumb already take. Reduced motion **skips** the transition by default and keeps the update, a whole-page cross-fade being exactly the motion the preference is about; `{ reducedMotion: 'play' }` is the opt-out, and `{ types: ['forward'] }` is what `:active-view-transition-type()` selects on.

Full reference: `node_modules/@box-kite/react/docs/props.md` in the project, or https://www.box-kite.dev/props.md — every prop, the CSS it writes and one measured example.
