Box Kite 2.1.0

21 September 2026
What changed in this version, written as it landed. The same notes are the body of the GitHub release.

21 September 2026 · npm · Compare v2.0.1...v2.1.0

A model can compose the UI now, and the app still owns it. catalog() says what may be built, <SpecRenderer> renders what came back against the components your app allows, and an agent's turn — the tool call, the approval, the reasoning, the prose as it streams — has components of its own. The package ships an MCP server beside them, so the agent writing your code can ask the real engine what a prop does instead of guessing. And the data grid became a spreadsheet: a marked block of cells, Ctrl+C, Ctrl+V and an editor in every cell.

Everything here is additive, so nothing you have written has to change. One thing to know before upgrading: a theme block now generates an @scope rule, which needs Chrome 118+, Safari 17.4+ or Firefox 128+ — below that the theme is dropped silently.

Highlights

What an AI may build, as JSON Schema

There is a new entry point, @box-kite/react/catalog. It answers the question a generative-UI runtime asks: which components may a model compose, and what may it put in their props? Everything else the package ships for AI is read at development time by whatever writes your code. This is the runtime half — what a model is allowed to build while your app is running.

JSX
import { catalog } from '@box-kite/react/catalog';

// The allow-list is yours: the library ships everything it can render, and the app says what it wants.
const allowed = catalog({ include: ['Flex', 'H2', 'P', 'Sparkline'], styleProps: ['d', 'gap', 'p', 'bgColor', 'fontSize'] });

allowed.components.Flex.props; // a strict JSON Schema, ready for a structured-output API
allowed.rules; // the dividers — what a schema cannot state and a prompt must

The reason this library can answer it at all is that the props already are a constrained, serializable design language: a Box tree and a JSON UI spec are the same thing written twice. So a colour prop comes out as a pattern over the palette rather than a free string, and a generated tree that asks for #ff00ff fails validation instead of painting an off-brand card. Every closed value list is an enum, every component schema is additionalProperties: false, and a prop the registry genuinely leaves open is { type: 'string' } with its listed values as examples — the catalog never states a constraint the library does not enforce.

Two sources meet in it, and neither would do alone. What a prop accepts is read off the live prop registry when you call catalog(), so a prop or a colour added by Box.extend() is in the catalog with nothing regenerated and no build step — call catalog() after the extend() that should be in it. What a prop means is generated from the same JSDoc as the prop reference, because no registry entry knows that fontSize divides by 16. What neither can say is in rules: the dividers, the millisecond times, the unitless SVG lengths. Put those in the prompt — they are the mistakes that still validate and still render.

Getting it into a runtime is one adapter. json-render wants Zod, and z.fromJSONSchema is the whole of it:

JSX
const jsonRender = schema.createCatalog({
  actions: {},
  components: Object.fromEntries(
    Object.entries(allowed.components).map(([name, component]) => [
      name,
      { props: z.fromJSONSchema(component.props), slots: component.slots, description: component.description, example: {} },
    ]),
  ),
});

One thing to know if you use that runtime, measured against @json-render/react 0.20.0: its propsOf schema type resolves to z.record(z.string(), z.unknown()) for any catalog holding more than one component, so jsonRender.validate(spec) checks which components a spec names and lets any props through. Each component's props is a self-contained schema for exactly this reason — check a node against its own before rendering it. The same schema serves a structured-output API, and any other runtime that takes a component catalog.

Function props are listed as events rather than described, since a JSON spec cannot carry a function and the host is where binding one belongs. A ReactNode prop is a slot. A prop whose type a JSON spec cannot express is left out rather than half-described.

The catalog

What a model wrote, rendered safely

A second new entry, @box-kite/react/spec, and the other half of the catalog above. The catalog says what a model may build; <SpecRenderer> renders what it built — against a registry the app puts together, which is the same allow-list one step further on.

JSX
import { catalog } from '@box-kite/react/catalog';
import Flex from '@box-kite/react/components/flex';
import { H2, P } from '@box-kite/react/components/semantics';
import SpecRenderer, { createSpecRegistry } from '@box-kite/react/spec';

const allowed = catalog({ include: ['Flex', 'H2', 'P'], styleProps: ['d', 'gap', 'p', 'bgColor', 'fontSize'] });
const registry = createSpecRegistry({ catalog: allowed, components: { Flex, H2, P } });

<SpecRenderer spec={spec} registry={registry} data={data} onAction={(action) => run(action)} />;

A node is { type, props, children, slots, on, repeat }, all of it JSON, and every field of it is checked before anything renders. A type the registry does not hold renders nothing. A prop the component's own schema refuses is dropped — the prop, not the node — so a colour has to be one the palette has and a value still half-written mid-stream simply does not paint until it is whole. 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 the app's, which is human-in-the-loop by construction. There is no tag that comes from the spec, no eval and no dangerouslySetInnerHTML anywhere in it.

A spec arrives in pieces, and that is the ordinary case. streamObject's partialObjectStream hands over the same object a few more characters at a time, so a node whose type has not been written yet renders nothing and reports nothing — it is a frame, not a fault. Every node also has an error boundary of its own, so a component that throws on props a model invented costs that node and nothing around it, and is tried again on the next frame.

Whatever did not render is reported rather than swallowed: onIssues gets a code (unknown-component, invalid-prop, unknown-event, unresolved-data, too-deep, render-error…) and the path in the spec it happened at. fallback is what stands where a node could not render, and renders nothing unless you say otherwise — a red box is a poor thing to show somebody who did not write the page.

Data stays the app's. A model writes the shape of a view and the host owns the numbers, so a prop can be { $data: 'stats.revenue' }, a child can be { $item: 'label' } inside a repeat, and a reference is resolved before it is validated: the value the host supplied is the one the schema judges. A path, never an expression.

And the other direction, so the constraint and the renderer cannot drift apart:

JSX
import { specSchema } from '@box-kite/react/spec';

const { partialObjectStream } = streamObject({ model, schema: z.fromJSONSchema(specSchema(registry)), prompt });

specSchema(registry) is one JSON Schema for a whole tree, built from the same rules the renderer enforces: the component names are an enum of what the app allowed, each one's props are its own schema, and children is offered only where there is a slot to put them in.

The entry is 4.4 KB gzipped and carries no engine at all — the components come from the app, so nothing in it imports Box. renderSpec(spec, options) is the same walk with no hook in it, returning { element, issues }, so a static spec renders on a server.

Generative UI

The whole loop, and the route that runs it

specSchema() is exported from @box-kite/react/catalog as well as from /spec, which is what makes the server half of a generated UI writable at all: the model call is a route handler, and the entry that renders a spec is a client entry. The catalog entry renders nothing, so a route can import it — and specSchema() takes a catalog() as readily as a registry, so the server side needs no components.

JSX
import { anthropic } from '@ai-sdk/anthropic';
import { catalog, specSchema } from '@box-kite/react/catalog';
import { jsonSchema, streamObject } from 'ai';

const allowed = catalog({ include: ['DashboardGrid', 'Widget', 'Sparkline', 'DataGrid'] });

export async function POST(request: Request) {
  const { prompt } = await request.json();
  const result = streamObject({
    model: anthropic('claude-sonnet-5'),
    schema: jsonSchema(specSchema(allowed, { bindings: true })),
    prompt,
  });

  return result.toTextStreamResponse();
}

The client half is <SpecRenderer> over whatever has arrived. It holds a node back now when a prop its component cannot do without has not arrived yet, reporting a new missing-prop issue: a Sparkline with no data reads undefined and throws, which its own error boundary caught — the same blank space, with a caught crash per frame behind it. Rendering nothing is the same picture without the noise.

One more rule a stream imposes, now in the catalog's own descriptions: a generated node writes the controlled prop and never the default… twin. React reads an uncontrolled default once, so the frame in which defaultLayout first arrived whole is the one that sticks — and a second spec rendered in the same place keeps the first one's state, since it is the same component instance. layout, value, open: every frame re-applies them, and the last frame is what stays on screen.

box-kite.dev/generative-ui is the loop end to end, and examples/next-app/app/generative is the live route, page and catalog in three files.

A dashboard people rearrange, and a model can write

There is a new component entry, @box-kite/react/components/dashboard, and it is two things: <DashboardGrid>, a grid of widgets that can be dragged and resized, and <Widget>, the chrome around whatever one of them shows. What passes between them is a layout — plain JSON in cells, with a version in front of it — and that one artifact is what a model emits, what a drag reports back and what your app stores.

JSX
import DashboardGrid, { Widget } from '@box-kite/react/components/dashboard';

<DashboardGrid layout={layout} onLayoutChange={setLayout} onLayoutCommit={save} editable>
  <Widget id="revenue" title="Revenue" description="Last 12 weeks">
    <Sparkline data={revenue} variant="area" width="100%" height="100%" />
  </Widget>
  <Widget id="orders" title="Orders" onRefresh={reload} loading={pending}>
    <Sparkline data={orders} width="100%" height="100%" />
  </Widget>
</DashboardGrid>;

Nothing is measured to lay it out. A widget's cell is grid-column and grid-row, which are props, so they are shared classes: a dashboard of any size costs no transform per item, no ResizeObserver and no inline style at rest — and the same layout renders on a server. The one inline style in the component is the translate that keeps a dragged widget under the pointer, which is a value per frame and would be a rule per frame that is never freed.

A drop takes the cell. A widget put on top of its neighbour keeps the cell it was dropped on, and the neighbour is handed one of its own — the row above where there is room for it, the row below otherwise. Everything then floats up, so a widget cannot be parked in mid-air, a drop below its neighbours rises to meet them, and two dashboards holding the same widgets in the same places compare equal.

Narrower is a projection, not a second layout. columns takes a count per container size — { xs: 1, md: 6, xxl: 12 } by default — and each narrower arrangement is the same layout scaled down by arithmetic at render time, written as a container query. The browser picks between classes; nothing listens for a resize. Two things fell out of building it, both measured in Chrome 153. 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: where the grid is showing a projection the handles are not rendered at all, because an edit made in six columns is not a layout in twelve.

Dragging is not a keyboard gesture, so the keyboard gets a grab. Both handles are real buttons: Enter or Space picks the widget up, the arrows move it a cell at a time — following the reading order, so ArrowLeft moves it right in a right-to-left page — Enter drops it and Escape puts it back, the layout with it. Every step is announced in a live region that exists before there is anything to say, and a grab that loses focus is cancelled rather than dropped somewhere nobody looked at. The grid is a role="list" of widgets, each one titled by a real heading at level.

A widget is chrome and four states. loading draws bars where the content will be and reports aria-busy, error replaces the content with the message and — with an onRefresh — a retry, empty says so in words rather than leaving a panel that looks broken, and anything else renders the children. Outside a DashboardGrid a Widget is simply a card with the same chrome.

The layout is the artifact, so it is also the prompt. DashboardUtils.SCHEMA is the layout as JSON Schema — inside the subset catalog() emits and <SpecRenderer> validates — and DashboardUtils.parse reads one back from wherever it was kept, dropping what it cannot use and reporting it rather than throwing. The schema says the shape and parse says the sense: a generated w: 0 or a column count of 400 is clamped, and two items claiming one id become one. Where a dashboard is kept is the app's decision, since only the app knows whether it belongs to a person, a team or a URL; the docs page keeps its demo in localStorage, which is the whole of it.

The entry is 5.99 KB gzipped on top of Box. The two style-tree nodes it adds are in the engine with every other component's, so they cost 0.44 KB gzipped on every entry that carries one, dashboard or no dashboard.

The dashboard

A generated dashboard with something in it

catalog() described the components a model may compose and, for four of them, not the props that carry what they are for. A <DataGrid> arrived with no def at all — though def is required — and a <DashboardGrid> with no layout, so a generated spec could place a dashboard and neither lay it out nor put a grid in it. Both are in the catalog now, and so are Widget's empty and ChartContainer's series.

AUTO
{
  "type": "Widget",
  "props": { "id": "orders" },
  "slots": { "title": ["Orders"] },
  "children": [
    {
      "type": "DataGrid",
      "props": {
        "data": { "$data": "orders" },
        "def": {
          "rowKey": "id",
          "footer": true,
          "columns": [
            { "key": "customer", "header": "Customer" },
            { "key": "total", "header": "Total", "align": "end", "aggregate": "sum" }
          ]
        }
      }
    }
  ]
}

These four are the props that are a shape rather than a value, and the reason they were missing is that the other half of each shape is React: a column carries a Cell renderer and an onCellEdit, a widget's empty is a ReactNode. The generator maps types, so it had to drop such a prop whole rather than state a constraint that is not true. What a spec can write is now described by hand beside the interface it mirrors — every key typed against it, so a prop renamed in the component is a compile error rather than a constraint that silently stops matching.

The part a spec cannot write is still absent: dataSource, onCellEdit, rowDetail and treeData are functions and components, passed beside the spec rather than in it, and a column's own nested columns would be a recursive schema. data is the one prop in the catalog that carries values rather than styling, so its schema says "objects" and stops — { "$data": "orders" } is the usual answer, and the rows stay yours. Everything else is judged the way any other prop is: a column with no key, an aggregate that is not one of the five, or a Cell written as a string fails the schema, so the prop is dropped and the node renders without it.

DashboardUtils.SCHEMA is unchanged and is what DashboardGrid's layout now points at, so the artifact a drag reports back, the one a host stores and the one a model generates under are one description in one place. It moved into a module of its own to get there, which is the whole cost of this: 1.83 KB gzipped on the catalog entry for the four contracts, 26 B on @box-kite/core for merging them, and 14 B on the dashboard for the module boundary. Nothing else moved.

The shapes a spec can write

An agent's turn, in three components

@box-kite/react/components/agent is the chrome around what an agent does rather than what it says: <ToolCallCard> for a call it made, <ApprovalCard> for one it wants permission to make, and <Reasoning> for the thought behind both. Typed, themed, keyboard-complete, and 1.7 KB gzipped on top of Box for all three.

JSX
import { ApprovalCard, Reasoning, ToolCallCard } from '@box-kite/react/components/agent';

<Reasoning duration={1400}>{reasoningText}</Reasoning>

<ToolCallCard name="searchOrders" status="success" input={{ orderId: 4182 }} output={{ total: 6400 }} />

<ApprovalCard
  title="Refund order 4182"
  description="6,400 MDL back to the customer. This cannot be undone."
  input={{ orderId: 4182, amount: 6400 }}
  onDecisionChange={(decision) => respond(decision === 'approved')}
/>;

The status is a word, not a colour. pending, running, success and error each carry their own label beside the dot, because a forced-colors mode throws a tint away and a screen reader never had one. The four are what every runtime already 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. A tool's arguments can be circular, hold a BigInt, or be four megabytes long — JSON.stringify answers those three with a throw, a throw and a frozen frame. AgentUtils.formatValue is the judgement, framework-free and exported from the same entry: the text is capped at valueLimit (20,000 characters) with a line saying how much was left, and a value that cannot be serialised is described instead of taking the transcript with it.

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. Two things it deliberately does not do: it does not take focus unless autoFocus says so — a turn arrives while the reader is somewhere else, and a card that grabs the keyboard is one that gets answered by accident, which is also why autoFocus lands on Reject — and it does not announce its own arrival, because the transcript it is rendered into is what does that. What it owns is the answer, in a role="status" that is in the DOM before there is anything in it, since a live region inserted together with its text is not reliably read out.

Everything else follows the library's own rules. A ToolCallCard with nothing to disclose renders no control at all, because a header that opens nothing is a tab stop nobody wants to land on. Reasoning is closed by default and opens in the same one-row grid an Accordion panel does, so nothing is measured. All three are in catalog(), so a generated UI can build a tool-loop transcript, and the trees are toolCall, approval and reasoning for Box.components().

What the agent says, as it arrives

<StreamingText> is the other half of an agent's turn: the message itself. Hand it the text so far and it fades in the part that was not there a render ago.

JSX
<StreamingText text={message} streaming={status === 'streaming'} />

Only what arrived animates, and the cost does not grow with the message. What is on the page is one settled string plus the last few runs to reach it — eight by default — so a message that is already whole paints at once with nothing moving, which is what a prerendered page and a transcript read back both want, and a message still arriving costs the same at the ten-thousandth token as at the first. window is that number: 0 turns the entrance off, and a stream fast enough to fill the window inside one transition is the case for raising it. The judgement is AgentUtils.advanceStream, framework-free like the rest of that namespace.

The entrance is @starting-style rather than a keyframe, so it rides --transitionTime and disappears under prefers-reduced-motion with nothing declared for it; the caret is the pulse preset, which stops itself for the same reason. It is deliberately not a live region — one announcing every token reads the message out a word at a time and again when it finishes — so what it carries is aria-busy, and what announces an agent's turn is the transcript it lands in.

The style tree is streamingText, with segment and caret under it.

Markdown, and the dependency we did not take

A model writes markdown, and a parser is a choice most apps have already made — so what ships is the half that is ours: markdownComponents, the components map that react-markdown, Streamdown and everything built on that shape already takes, with this engine's classes on it.

JSX
import Markdown from 'react-markdown';
import { markdownComponents } from '@box-kite/react/components/markdown';

<Box component="markdown">
  <Markdown components={markdownComponents}>{message}</Markdown>
</Box>;

Wrapping a renderer was the other option and it is not worth it: the ones on offer ask a project for a build-tool directive pointing into their dist/ and for design tokens declared in a global stylesheet — which is the one thing this library exists not to need — and it would choose the parser for you. The map costs no dependency, works with whichever renderer is already there, and keeps the promise: no stylesheet.

It is a constant, not a factory, and while streaming that is the whole difference. A map built inside render is a new set of component types every token, which React answers by unmounting the message and mounting it again; override a node by spreading at module scope instead. Whether a URL is safe stays the renderer's, because by the time a component is called the href has been parsed — urlTransform or defaultUrlTransform is where a javascript: link is refused. What the map sets is rel="noreferrer".

1.05 KB gzipped on top of Box, and the tree is markdown with heading, paragraph, link, list, item, quote, code, codeBlock, rule, image, table, row, cell, inline and checkbox under it.

Where the answer will be

<Skeleton> is the placeholder while something is being fetched: bars where the content goes, with a gloss crossing them.

JSX
<Skeleton lines={3} label="Loading orders" />
<Skeleton circle width={10} />

With no label the whole thing is aria-hidden, because a reader told "three empty bars" has been told nothing; a label makes it a role="status" naming what is on its way, and it belongs on the one skeleton standing for a region rather than on each bar. The gloss is a named duration, so it sits outside what --transitionTime zeroes and stops itself under prefers-reduced-motion. It renders on a server — no state, no effect, no measurement — and costs 0.35 KB gzipped on top of Box. The tree is skeleton, with bar (whose short and circle variants are the last line and the avatar) and gloss.

The whole loop is in examples/next-app now: /agent is a real AI SDK tool loop where every part of a turn is one of these components. AI SDK reports six tool states — four are a <ToolCallCard> status, and the other two are an <ApprovalCard>, because a decision is not a stage a call passes through but a question somebody has to answer.

Four agent runtimes, one vocabulary

@box-kite/react/interop is this library's shapes and the agentic ecosystem's, mapped onto each other. It imports none of them: an adapter that pulled in a runtime would be choosing one for your app, and your app has already chosen.

JSX
import { toolPart, a2uiApply, a2uiSurface, a2uiToSpec, a2uiCatalog } from '@box-kite/react/interop';

const mapped = toolPart(part); // AI SDK, assistant-ui or CopilotKit
<ToolCallCard name="searchOrders" status={mapped.status} input={mapped.input} output={mapped.output} />;

Every runtime describes the same two things in words of its own — a tool call, and a tree of components. toolPart(part) answers the first in the words <ToolCallCard> and <ApprovalCard> already take, and the split worth knowing is that a decision is not a stage a call passes through: it is a question somebody answers, which is why two components cover what AI SDK reports as six states, and why the three runtimes reporting four keep their human-in-the-loop on a second channel. AG-UI is the odd one out — it reports events rather than parts, so applyToolEvent(parts, event) is a fold rather than a mapping.

A2UI is the one that is genuinely a different shape, and so the one with real code behind it: a flat adjacency list of components referring to each other by id, arriving one message at a time, with a data model of its own per surface. a2uiApply folds a v0.8 or v0.9 stream, and a2uiToSpec walks a surface into the tree <SpecRenderer> renders.

JSX
const [state, setState] = useState(a2uiEmpty);
const surface = a2uiSurface(state);

<SpecRenderer spec={a2uiToSpec(surface, { catalog })} registry={registry} data={surface?.data} onAction={run} />;

Three things the two models turned out to already agree on. A2UI's data binding is a JSON Pointer and $data has taken one since it was written, so a binding is a rename rather than a parse; a template (children: { componentId, path }) is one node per item of an array, which is what repeat means; and a half-arrived surface is the ordinary case, since an agent streams a leaf before the branch that holds it. The other direction is a2uiCatalog(catalog()) — the same components and the same values their props take, in the shape an adjacency list needs, which is what an agent generates against.

assistant-ui's GenerativeUISpec is the same idea arrived at twice, so fromGenerativeUi and toGenerativeUi are a rename plus an honest accounting: their nodes carry no data binding, no repeat and no action channel, so the second reports what it could not carry rather than emitting a tree that renders half a view in silence.

Every runtime named is a devDependency of the repository and the adapters run against the published packages rather than against a memory of them: @assistant-ui/core's own types accept what toGenerativeUi emits, @copilotkit/a2ui-renderer's createCatalog accepts the document a2uiCatalog builds, and a surface generated against that document renders here. One trap came out of it, measured against zod 4.6: a $ref resolves against the document, not against the piece you lifted out of it, so converting a catalog component on its own throws Reference not found: #/$defs/color — a2uiComponentSchema(document, name) is that component with the document's definitions attached.

3.96 KB gzipped, and no engine in it. The recipes per runtime, AG-UI and json-render included, are in docs/interop.md and at box-kite.dev/interop.

npx @box-kite/mcp: the answer a documentation file cannot give

A file is read once, at the start of a session. An MCP server is asked mid-task, which is when the question actually comes up — and it can answer one thing no file can.

Every prop here accepts a closed set of values, and a value it does not accept writes no rule and no class name. Silently, by design: a typo must never emit a broken declaration into a stylesheet everything else shares. That is the right behaviour and it is invisible, so no amount of prose settles whether bgColor="blue-550" works. check_styles hands your props to the real engine and reports what each one wrote:

Terminal
check_styles { "props": { "p": 4, "bgColor": "blue-550", "fontSize": 14, "href": "/about" } }

✅ p         → .p-4{padding:1rem}
❌ bgColor     does not accept "blue-550" — no rule and no class name were written.
✅ fontSize  → .fontSize-14{font-size:0.875rem}
⚠️ href        an HTML attribute, not a style prop. It goes in props={{ "href": … }}.

For the same reason get_props measures a numeric prop's scale instead of describing its divider — it runs 1, 2, 4 and 8 through the engine and prints what came out — and an unknown prop is answered with the props that write the CSS property its name spells, so padding comes back as p and backgroundColor as bgColor.

Terminal
claude mcp add box-kite -- npx -y @box-kite/mcp

Any client that speaks stdio takes the same command:

AUTO
{
  "mcpServers": {
    "box-kite": { "command": "npx", "args": ["-y", "@box-kite/mcp"] }
  }
}

Six tools, at capability level rather than one per document. search_docs ranks the props, the components, the nesting keys and the rules together, so "fade in when it mounts" answers startingStyle and "style every other row" answers nth without either name being known; get_component carries a component's props, its sub-parts, its keyboard map and the ARIA it writes; get_rules and get_blocks are the rules themselves and the sections the shadcn CLI installs.

No key, no network and no state: the prop reference, the component reference, the rules and the styling engine are all built into the package at the version you install, so check_styles and get_props cannot disagree with each other or with the library you are writing against. context7.json ships in the repository too, for the aggregator half of the same job.

A block of cells, and Ctrl+C

Every DataGrid now marks the cell its arrows carry on from — whether the pointer or the keyboard put it there, and it stays marked once the grid loses focus. Ctrl+C on it copies that cell. def.rangeSelection is the rest: drag across cells, or hold Shift with the arrow keys, and the block that is marked is what Ctrl+C writes, tab-separated — the text Excel, Sheets and Numbers all paste as columns.

JSX
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    rangeSelection: true,
    columns: [
      { key: 'first_name', header: 'First name' },
      { key: 'country', header: 'Country' },
      { key: 'age', header: 'Age', align: 'end' },
    ],
  }}
  onRangeChange={(range) => setSummary(range?.values())}
/>

The mark is state on the grid rather than a :focus-visible ring, which is what fixes it: that pseudo-class never matches a pointer, so a clicked cell drew nothing, and a ring made of focus goes out the moment the grid loses it — leaving a copy with nothing to act on. The cells of a block report aria-selected and the grid is aria-multiselectable; a lone current cell reports neither, because it has chosen nothing. onRangeChange hands over the rectangle and values(), which reads what is in it through the same pipeline an export uses — a column's exportValue where it has one, and an accepted edit over the row — so the figure a status bar sums is the figure a .csv would carry.

A drag can mark cells or select text and never both, so a grid with rangeSelection on gives text selection up; an open editor hands it back for the value being typed. A touch is left alone entirely, since that press is how the grid scrolls.

Range selection and copy

Ctrl+V, one judgement per cell

A block of cells goes back the other way now. Ctrl+V 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 a paste needs no second validator, no second event and no second way of writing a value. onPaste reports the whole block once: what was written, what was refused and with which message, and how many cells nothing could be written to.

JSX
<DataGrid
  data={people}
  def={{
    rowKey: 'id',
    rangeSelection: true,
    columns: [
      { key: 'first_name', header: 'First name', editable: true },
      { key: 'salary', header: 'Salary', align: 'end', editable: true },
    ],
    onCellEdit: ({ columnKey, value }) => (columnKey === 'salary' && Number(value) < 0 ? 'Salary cannot be negative' : undefined),
  }}
  onPaste={({ applied, rejected }) => setReport({ written: applied.length, refused: rejected.length })}
/>

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 — a paste onto the current cell alone starts from a block of one.

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 and report aria-invalid: there is no editor open on any of them to show a message in, so the cells say which ones and onPaste says why. Since the clipboard carries no types, the cell is what says how to read the text — a number column refuses what is not a number, a checkbox takes true/false, and a select keeps to its own options and holds the option's value rather than its spelling.

A paste that reaches an open editor belongs to the editor: that is a value being typed, not a block being filled. Everything a paste accepts lands in the same stream a typed value does, with a reason of paste.

Ctrl+V, one judgement per cell

A fling renders where you are going

A DataGrid kept twenty rows rendered on each side of its viewport, whichever way the rows were actually moving — fifty-eight of them around an eighteen-row screen, roughly twice what a virtualized body needs. A buffer is cover for the frame between a scroll and the render that answers it, so it is only ever wanted in the direction of travel. It is twelve rows ahead and four behind now, and it turns round when the reader does. Nothing to configure, and no prop changed.

Thirty-six rows instead of fifty-eight, on the same quiet laptop the benchmark's published figures come from: the median frame of a fling over a hundred thousand rows went from 12.3 ms to 8.0 ms — 81 frames a second to 125 — with the work inside the frame down from 19.8 ms to 15.3, and first render 89 ms to 81, filter 65 to 42 and sort 89 to 76 carried along with it.

The number that keeps that honest is new, because a grid that renders nothing at all is the fastest grid on the page. The benchmark now flicks each grid past at ten thousand pixels a second — the top of what a hard flick reaches, five rows between one frame and the next at 60 fps — and hit-tests four points down it at the start of every frame, against the scroll position that frame is about to paint. This grid paints every one of those frames. With the buffer taken away altogether the same pass reports every frame blank, which is what says it is looking at something; with eight rows of cover instead of twelve, four frames in a hundred.

The benchmark

A hundred thousand rows, and a page that measures them

There is a benchmark page on the docs site now — box-kite.dev/benchmark — and it is not a table of numbers somebody typed in. It generates a hundred thousand rows of twenty columns in your own browser, drives the grid through five operations and reports what it measured on your machine: first render, a two-second fling, a column filter, a sort and a grouping with totals. What it leaves out is on the page beside the numbers, because a benchmark that only flatters the thing it measures is an advertisement.

Writing it found two renders the DataGrid was doing for nothing, and both are fixed here. A scroll that did not change which rows were on screen was re-rendering every cell in the window; it now costs the transform and nothing else. A scroll that brought one new row in was re-rendering every row in the window; it now renders the row that arrived. On the machine the published figures come from, the median frame of a fast fling over a hundred thousand rows halved, from 24 ms to 12 ms — forty-one frames a second to eighty-two, with the worst frame down from 38 ms to 24 — and not a prop changed.

What it says about this grid, on one quiet laptop, five runs of a hundred thousand rows by twenty columns: first render, filter, sort and grouping all land inside a frame — and the fling was the one it was slowest at, about 20 ms of work a frame, eighty-one frames a second. That is what the next section is about: the fling is inside a frame now, and what is left is the work inside it.

npm run bench is the same measurement headless, and it is the whole harness: the page is the benchmark and the script only presses its button, so a rerun in CI measures the code a reader measures. It runs on every pull request that touches the grid or the engine, against budgets sized for a shared runner — and only for this grid.

The benchmark

npx shadcn add @box-kite/data-grid

There is a shadcn registry on the docs site now, and three blocks in it — an invoices data grid, a settings form and a dashboard shell. A block is not a component: it is a finished section that the CLI writes into your own repository, so the composition is yours to edit from the first commit while the components under it stay a package you upgrade.

Terminal
npx shadcn@latest add https://box-kite.dev/r/data-grid.json

Register the namespace once in components.json and the address becomes a name — npx shadcn@latest add @box-kite/settings-form, and npx shadcn@latest search @box-kite lists what is there:

AUTO
{
  "registries": {
    "@box-kite": "https://box-kite.dev/r/{name}.json"
  }
}

No stylesheet anywhere. The CLI expects a components.json carrying a tailwind key, and empty strings satisfy it, because these blocks import no stylesheet and write no CSS file — every style in them is a prop. Verified end to end against a fresh create-next-app: the files land, lucide-react installs with the dashboard shell, and next build prerenders the page with the CSS in the HTML.

The blocks are ordinary sources in the library's own repository, type-checked by the same tsc run as the library and rendered live on the registry page from those same files, which are inlined into the JSON at build time. So a block that stops compiling cannot be published, and what the CLI writes is what the page is running.

The blocks

The DataGrid exports its types

Every other component exports its own types; components/dataGrid exported only the component. Typing a column list, a ref or a cell renderer outside the JSX therefore meant importing from @box-kite/react/components/dataGrid/contracts/dataGridContract, a path that is plainly private. The whole contract comes off the component's own module now:

JSX
import DataGrid, { type CellModel, type ColumnType, type DataGridHandle, type GridDefinition } from '@box-kite/react/components/dataGrid';

function StatusCell({ cell }: { cell: CellModel<Invoice> }) {
  return <Badge>{cell.value as string}</Badge>;
}

const definition: GridDefinition<Invoice> = { rowKey: 'id', columns: [{ key: 'status', Cell: StatusCell }] };

Types only — nothing is added to the bundle, and the deep import still resolves, so nothing that works today stops working.

A theme inside a theme

A theme is an ancestor class, so two of them on one page wrote two rules of exactly the same specificity — and the one written last won, however far away it was. That made <Box.Theme use="local"> a coin toss: a light panel inside a dark page stayed dark, and every built-in component's dark styling stayed with it, since those are written as a dark override over a light base rather than as a pair of themes.

Every theme rule is scoped to the subtree its theme owns now, and ends at the next element declaring one:

CSS
@scope (.light) to ([data-theme]) {
  :scope .theme-light-bgColor-white {
    background-color: var(--white);
  }
}

So the nearest theme wins — for a property the inner theme never mentions as much as for one it does, and even where the outer theme's rule carries one more pseudo-class, which proximity alone would have lost to. What marks a theme root is data-theme, and <Box.Theme> renders it beside the class instead of writing it from an effect, so the boundary is in the HTML the first paint uses. A theme class you set by hand — the one a prerendered shell puts on <html> — wants the attribute beside it.

JSX
<Box.Theme use="local" theme="light">
  <Box p={4} theme={{ light: { bgColor: 'white' }, dark: { bgColor: 'slate-950' } }}>
    Light in here, whatever the page around it is.
  </Box>
</Box.Theme>

Theme setup

Theming now needs a 2023 browser

Nothing in the API changed, so nothing you have written has to change — but the rule this release generates for a theme block is an @scope block, and that is Chrome 118+, Safari 17.4+ or Firefox 128+. Below those versions the theme rules are dropped rather than misapplied: an element shows its unthemed values, which for every pre-built component is the light design, because each of their theme blocks is a dark override over a light base. There is no error and nothing in the console — it simply looks unthemed.

If you support a browser below that floor, write the design it should get as the plain props and the other one under theme, which is what the pre-built components already do:

JSX
// The old browser gets the light design; the new one gets either.
<Box bgColor="white" color="slate-900" theme={{ dark: { bgColor: 'slate-900', color: 'white' } }} />

A site about this library, and an answer for the agent writing it

box-kite.dev no longer argues with other libraries. The three comparison pages — the utility-family parity table, the accessible-pattern comparison and the data grid feature matrix — have been removed, along with the picker that ran the benchmark against three other grids and every versus line scattered through the component pages. A documentation site that argues with other libraries is a site about those libraries, and a reader who arrived to find out what this one does had to read past the argument to get there.

The numbers were worth keeping, so they were kept. They are a development instrument now rather than a page: npm run compare prints all three tables in a terminal, and takes a section name to print just one. The data and its tests moved to dev/compare/, and the tests still run in npm test — they hold each table to the live prop registry and to this site's own routes, so a prop added and not mapped still fails the suite. npm run compare runs in CI too, which proves the report prints; the tests are what prove it is right. The benchmark page keeps its Run button and measures this grid alone, with the other three adapters in dev/compare/grids/ and wired in outside a production build — so npm run dev still offers the four-way picker.

What the site leads with instead is the thing that is actually unusual about it. Everything an agent needs to write this library correctly ships inside the package, generated from the prop registry at build time: AGENTS.md, docs/props.md with every prop's measured example, a docs folder beside it, the skill, the Cursor rule — and npx @box-kite/mcp, whose check_styles runs the real engine, because a value this library refuses writes no CSS and says nothing about having done so. The homepage is four claims in that order now (the instructions ship with the code, the compiler catches the rest, a model can compose the UI at runtime, and what it writes is finished), /ai-context has become Built for AI and sits at the top of the nav, and Generative UI, Interop and Agent are a section of their own.

The /migrating page is gone with them — it existed for a rename nobody had to follow, and the three things the compatibility bridge cannot forward are still written down in the 1.0.0 notes and in the bridge's own README, which is where somebody still on the old package would be reading.

Nothing in the library changed. This is the documentation site, the README and one bench/results.json trimmed to the runs it still prints.

Built for AI

/box, rebuilt for somebody who has never seen this library

/box is the page every component's API reference points at — "all 221 of Box's style props work on it too, and those are on /box" — and it was the page that taught the least. It opened on a nine-tab switcher over forty-six hand-written demo cards, with no sentence anywhere saying what a Box is, what a number on a prop means, or why there is no style attribute. The four facts that are silently wrong against a memory of another library were written down for a machine, in AGENTS.md, and nowhere on the site for a person.

It reads top to bottom now, with nothing to click before the first sentence: what a Box is, then the five things to know first — each one printing the guess small and the truth in the heading, with the wrong line and the right line side by side — then the numbers, then a card built one prop at a time over six steps, then the nesting keys, then the nine props that are not styles at all (tag, props, component, style and why it exists).

The numbers section is measured rather than described. Put a number in the box and every scale family shows the CSS it really writes for it, resolved by an isolated createStyleEngine() running in the page — so p={16} is padding: 4rem and fontSize={16} is font-size: 1rem, side by side, and the divider cannot drift away from the engine that owns it.

The nine tabs are one search box. Every one of the 221 props is in a single table with the CSS it emits, filterable by twelve families and searchable by prop name, by CSS property or by a value you are looking for — read from api/props.json, which npm run docs:props measures from the engine, so no example on the page is written by hand. The forty-six demo cards covered a fifth of the registry and were the last prop documentation on the site that a person maintained.

/box-functions is the other half, and it is new: Box.extend(), Box.components(), Box.keyframes(), Box.spring(), Box.Theme, Box.useTheme(), Box.configure(), Box.getVariableValue(), useClassNames() and useVisibility(), each with its signature, the smallest real example and a link onward. Three of them were documented nowhere at all, including useClassNames(), which is the only way to put Box props on an element this library does not render.

Nothing in the library changed.

Box · Box functions

Breaking changes

None.

Fixes

  • A component documented none of the props it inherited from another component. Gauge is a ProgressRing with a shorter sweep, so its value — the whole point of a gauge — was missing from its API page, from the catalog and from the manifest, and a generated <Gauge> could not be given one; the four chart primitives were missing the label that keeps them out of aria-hidden, and Menu.CheckboxItem/Menu.RadioItem were missing disabled. The extraction follows a props interface into whatever this repository declared behind it now, and stops where Box's own props begin. (#185)
  • A grid was unmounted and rebuilt half a dozen times in the closing moments of a stream. A stream is not monotone: a column's align passes through "e" on its way to "end", which fails its own schema and takes the whole def with it, so the prop the grid cannot render without went missing every few frames. <SpecRenderer> keeps the last value each node was given for such a prop — what a node has been shown with, it is not stripped of — and a heavy component can be gated by the app on top of that: point its name at a placeholder in the registry while the spec is arriving. (#188)
  • A generated component was handed undefined for a prop it cannot do without. A Sparkline whose data had not arrived yet — or was refused — threw, and only the node's own error boundary caught it. The renderer holds such a node back and reports missing-prop instead, so a streamed spec paints the same thing with nothing thrown behind it. (#186)
  • The catalog said a layout component could hold nothing. Flex, Grid, Button, Icon, Overlay and eleven others reported no default slot, because they declare no children prop of their own — they take Box’s props whole — while Img reported one it cannot have. A generated tree read off that catalog could not nest anything in a Flex. Every component that can hold children says so now, and one whose element takes none (Img, Textbox, Textarea) says that instead. (#180)
  • A DataGrid rendered fifty-eight rows around an eighteen-row viewport. Twenty rows each side became twelve ahead of the scroll and four behind it, so the same cover costs thirty-six rows: a fling over a hundred thousand rows went from 12.3 ms a frame to 8.0, and first render, filter and sort came down with it. (#178)
  • A DataGrid re-rendered every cell on screen on every scroll event. A scroll that does not change which rows are shown now costs nothing but the transform, and one that brings a row in renders that row rather than the window it landed in — the median frame of a fast fling over a hundred thousand rows halved, 24 ms to 12 ms.
  • A DataGrid cell chosen with the pointer showed nothing, and no cell stayed marked once the grid lost focus. The mark is the grid's own state now rather than a :focus-visible ring, so a clicked cell wears it, it survives a blur and a scroll, and Ctrl+C has something to copy. (#64)