Box Kite 2.0.0
16 September 2026 · npm · Compare v1.0.0...v2.0.0
The package now carries instructions for the agent writing the code, the documentation site answers in markdown, the same rules install as a skill in about forty-five coding agents, and every component page states its own props, keys and accessibility — all of it generated from the sources the library is built from. A floating layer is also placed by the browser now rather than measured in JavaScript, all the way through: six props, one hook, and every popup the library ships standing on them. Three of those popups are now browser features rather than components: a popover on the Popover API, a modal dialog on <dialog>, and a menu button whose submenus are popovers nested inside it. Tabs arrive beside them, on nothing but the keyboard the pattern asks for — with an indicator that travels between them and a panel box that resizes to fit. An accordion arrives with them, opening its panels on a grid track nobody had to measure, and a slider and a progress bar close the set — the two places the platform’s own control cannot be styled or given a second thumb. Last in are the messages an app sends rather than renders: one <Toaster />, and toast() from anywhere at all.
Highlights
- The package tells an agent how to use it —
AGENTS.mdand adocs/folder ship in the tarball, generated from the prop registry and the built chunks themselves. - Every docs page is also markdown — append
.mdto any address, or start atllms.txt, which indexes all of them and states the four facts a model's priors get wrong. - The rules install as a skill —
npx skills add box-kite/box-kitefor about forty-five coding agents, a plugin marketplace for Claude Code, and a.mdcfor Cursor. - Every component page states its own API — props, keys, accessibility and the style tree, generated from each component's types and doc tags, so a table cannot go stale.
- Three props for a table, and a list that shows its markers —
borderCollapse,borderSpacing,tableLayout, plusdisplay="list-item"and the markerslistStylewas missing. - A floating layer is six props, and no JavaScript — CSS anchor positioning: name an anchor, say which cell of the grid around it to sit in, and let the browser flip the layer when it does not fit.
- One hook places a floating layer —
useAnchorPositionfrom@box-kite/react/anchor, plusanchor-size()on every sizing prop andanchor()on every inset, so a popup matches its trigger with nothing measured. - Every popup in the library is placed by the browser —
Overlay,Tooltip, theDropdownpopup and the DataGrid column menu stand on that hook, three home-grown flip heuristics are gone, and oneside/align/offset/flipvocabulary replaces the transforms they took. - A popover is a browser feature now, not a portal —
<Popover>on the platform Popover API: the top layer, light dismiss and focus return are the browser's, so there is no portal and no z-index — and it is 3.46 KB gz on top of Box. - Every floating layer is in the top layer —
Overlay, and soTooltip, theDropdownpopup and the DataGrid column menu: no portal anywhere in the library, so a popup inherits the theme around it and a dropdown inside a panel no longer dismisses it. - A modal dialog is a browser feature too —
<Dialog>and<AlertDialog>on the native<dialog>:showModal()supplies the top layer, the backdrop, an inert page, Escape, focus containment and focus return, so the two together add 2.15 KB gz on top of Box. - A menu, and its submenus, are nested popovers —
<Menu>is APG's menu button on the Popover API: seven parts, the whole keyboard, submenus that nest as deeply as the markup, and 6.36 KB gz on top of Box. - Tabs, where selection follows focus — the APG tabs pattern in four parts: one tab stop for the list, arrows that follow the reading order, both activation modes, and 3.03 KB gz on top of Box. An indicator that travels between tabs and a panel box that resizes between panels are each one opt-in away.
- An accordion whose animation is a class —
<Accordion>andCollapsible, where a panel opens in a grid track rather than a measured pixel: noResizeObserver, nothing written per instance, and 1.80 KB gz for both. - A slider whose value keeps its own shape —
<Slider>takes a number for one thumb and an array for a range, so nothing at the call site has to narrow;<Progress>is the bar beside it, and renders on a server. 2.14 KB gz and 0.31 on top of Box. - A message you send rather than render —
<Toaster />once, thentoast()from anywhere at all: a live region that exists before there is anything in it, a queue rather than a cap, and timers that stop on hover, on focus and off screen. 4.14 KB gz on top of Box, styled. - A combobox whose value is your own row — the pattern most libraries leave you to assemble:
<Combobox>takes your rows and hands one back, the filter composes, the selection can be chips, a query nothing answers can become a row, and a list of ten thousand opens in one frame. 9.53 KB gz on top of Box. - The component contract, written down and enforced — five rules every component keeps: state in
useControllableState, every change reported with a named reason, Box props on every part, a style tree to replace, and a render prop instead ofasChild. A check with two ledgers that both fail on a stale entry is what keeps them true. - A column that adds itself up —
aggregateon a DataGrid column totals it over each group row and over a pinned footer of grand totals: five built-ins or a function of your own, respecting the filters, formatted by anAggregateCell. - The DataGrid, restyled — tabular numerals, a selected row that finally looks selected, pinned columns that float rather than fence, and chrome quiet enough to read the data through.
- Excel and CSV, with nothing to install —
def.exportwrites an.xlsxwith its groups as Excel outline levels and a CSV beside it: 3.80 KB gz behind a dynamic import, no ExcelJS, against the $999/dev/yr the same feature costs elsewhere. - A grid that fetches its own rows —
def.dataSourceis one function the grid asks for a block at a time: a million rows scrolled with no array in the page, sort and filter round-tripped, a superseded request aborted and a late answer dropped. +2.51 KB gz, against the $999/dev/yr the same row model costs elsewhere. - A group the server counts, and children it fetches when you open one —
grouping: trueputs Group By back with a datasource set: the request carriesgroupByandgroupKeys, a group row brings its own count and totals, and a group nobody has opened costs the page nothing. +1.45 KB gz. - A grid whose rows hold rows —
def.treeDataturns the rows into a tree, nested in the data or named by a path on each row: a chevron and an indent on one column, atreegridwith the keyboard APG asks for, a filter that keeps the path to a match, and nothing built under a row nobody has opened. - A tree the server holds, one level per chevron —
def.treeDatabesidedef.dataSource: the request carriestreeKeys,hasChildrensays which rows have a level to ask for, and 144,732 rows cost the page one of them. - Grouping can be declared now, not only clicked —
def.groupByanddef.groupDefaultExpanded: a grid that starts grouped, which is what a server render, a fixture and a demo all needed. - A cell you can type into, and one place that judges it —
column.editableplusdef.onCellEdit, which both validates an edit and is told about it, sync or async: four editors read off the value, APG's keys, a refused value announced in arole="alert", and an edit stream for a host's undo. - The grid is one tab stop, the way APG says — everything a DataGrid draws inside a cell leaves the tab sequence, so crossing a grid is one press rather than seventy-eight: Enter or F2 steps in, Escape hands the keyboard back, Space selects the row, and
def.tabNavigation="cells"is the other reading.
The package tells an agent how to use it
A coding agent has priors about a library this new, and they are wrong: the prop names collide with other libraries' while the numbers mean something else, so the failure mode is code that compiles and is laid out wrong. The cheapest fix is a file in the tree it already greps, so the tarball carries one:
node_modules/@box-kite/react/
AGENTS.md the rules, and the block that argues with the model's priors
docs/props.md all 221 props, the CSS each writes, and one measured example
docs/components.md every component, its import, and whether it renders on a server
docs/a11y.md the behaviour hooks, for a pattern the library does not ship
BOX_KITE_AI_CONTEXT.md the long-form reference, as beforeAGENTS.md is the file agents look for: Codex, Cursor, Copilot, VS Code, Windsurf, Cline and Zed read a root one natively, and Claude Code reads CLAUDE.md, so a file holding the one line @AGENTS.md points it at the same place.
cp node_modules/@box-kite/react/AGENTS.md ./AGENTS.mdNone of it is written by hand. docs/props.md comes from the prop reference, where every example is measured from the CSS the engine emits rather than quoted from a doc; docs/components.md is read out of the built chunks' own exports; AGENTS.md inlines the rules file. The build fails if a generated file comes out empty, if a component no longer loads, or if the prop count disagrees with the registry.
Every docs page is also markdown
The other half of the same problem: an agent that fetches a documentation page gets an HTML shell and a JavaScript bundle, and its usual answer is to guess instead. Every page on box-kite.dev now answers in markdown at its own address with .md appended, and llms.txt is the index — one line per page, the four facts to read before writing any props, and the props and components that still work under an older name.
curl https://www.box-kite.dev/llms.txt # the index: every page, and what each one covers
curl https://www.box-kite.dev/box.md # any page, as markdown — append .md to the address
curl https://www.box-kite.dev/props.md # every prop, the CSS it writes, one measured example
curl https://www.box-kite.dev/llms-full.txt # all of it in one file, for a tool that indexes a siteThe pages are converted from the rendered HTML during the build, not written a second time: a mirror that can fall behind the page it mirrors is worse than none, because it is the copy an agent trusts. props.md is the same file the tarball carries as docs/props.md, from the same generator, and the facts in llms.txt are read out of AGENTS.md — so there is one statement of each of them in the repository. Both forms of the address work, /box.md and /box/index.md, and each page names the address it is a copy of.
Every page also links its own markdown at the foot of the page.
The rules install as a skill
The third address for the same instructions, and the first one that is a command rather than a copy. Agent Skills are an open standard about forty-five coding agents read, so one SKILL.md reaches nearly all of them — and unlike a file copied into a repository, an install command brings the current one:
npx skills add box-kite/box-kite # Claude Code, Cursor, Codex, Copilot, Gemini CLI, Zed…
npx skills add box-kite/box-kite -a cursor -g # one agent, and globallyIn Claude Code the repository is also a plugin marketplace, so the skill installs and updates with the plugin machinery:
/plugin marketplace add box-kite/box-kite
/plugin install box-kite@box-kiteCursor takes the same rules as a rule file of its own, glob-attached to the files where props get written rather than loaded on every request:
mkdir -p .cursor/rules
cp node_modules/@box-kite/react/.cursor/rules/box-kite.mdc .cursor/rules/
curl -O https://www.box-kite.dev/box-kite.mdc # or without the package installedThe skill is shorter than the file it replaces and says more. Its body is the four facts a model's priors get wrong, the twenty-nine rules, a divider table measured from the engine — p={4} is padding: 1rem and b={4} is border-width: 4px, the same number twice — the deprecations, and a table of contents. The depth moved into four references it loads only when a question needs one: styling.md (every prop by category, the six kinds of nesting, the theme), components.md (which component replaces which <Box tag>, and the Dropdown, Select and DataGrid), extending.md (Box.extend() and Box.components()) and patterns.md (server rendering, the behaviour hooks, portals, form controls, the tooltip). A skill's body loads in full every time it is used, so what an agent pays for on a p={4} question dropped from 49 KB to 21 KB.
All three files are generated from the sources the library is built from — the rules file, the prop reference, the lead block of AGENTS.md, the @deprecated tags — and CI fails if one has been edited by hand instead of its source. The docs site serves the first two at skill.md and box-kite.mdc, generated the same way, for an agent that can fetch a URL but not run a command; llms.txt lists both.
Every component page states its own API
Every component page on box-kite.dev now ends in four sections — props, keyboard, accessibility and style tree — and not one of them is typed out by hand:
- Props come from the component's own props interface: the name, the type as the source writes it, the default read out of the destructuring, and the description from its JSDoc — which is the same text an editor shows on hover. Only the props a component adds are listed; all 221 Box props apply too and stay on /box.
- Keyboard comes from
@keyboardtags on the component, one per row. A component with two maps says which is which: the Dropdown lists select-only andisSearchableseparately, and the DataGrid lists the column resizer apart from the grid. - Accessibility is the APG pattern it implements, the roles and ARIA it owns as
@a11ylines, and what the axe sweep covered — by fixture name, so "swept" says which states were swept. - Style tree is read out of the component styles themselves: every node
Box.components()can restyle, its variants, and what it extends.
The point of generating it is the failure mode it removes. A keyboard table written beside a component is true on the day it is written; CI now fails when a documented prop has no prose, when a documented key is one no test presses, and when a component's page renders no reference at all — which is how four components turned out to be claiming keys that nothing exercised. They have keyboard tests now.
npm run docs:components # write api/components/*.json from the components
npm run check:components # fail when the reference, the tests or a page have driftedThe markdown mirror carries all of it, so curl box-kite.dev/switch.md is the whole reference as tables.
Three props for a table, and a list that shows its markers
Box is display: block whatever element it renders, which is right until the element brings its own layout — and then there was no prop to say so. The values arrived with the SVG work; the properties that make a table look like a table did not:
<Box tag="table" display="table" borderCollapse="collapse" tableLayout="fixed">
<Box tag="tbody" display="table-row-group">
<Box tag="tr" display="table-row">
<Box tag="td" display="table-cell" px={3}>Ada</Box>borderCollapse—collapseorseparate, the value that makes a bordered table read as one grid.borderSpacing— the gap between aseparatetable's cells, on the ÷4 spacing scale, soborderSpacing={2}is0.5rem.tableLayout—fixedsizes a table from its own width and first row, so one long cell cannot widen its column.
A list had the same gap from the other direction: display gained list-item, and listStyle the markers it was missing (disc, circle, decimal beside square), so a <Ul> built from Boxes shows bullets and keeps the list semantics Chrome derives from that display value.
A floating layer is six props, and no JavaScript
Positioning a tooltip, a popup or a badge against something else has meant measuring: read the trigger's rectangle, work out whether the layer fits, listen for scroll and resize, and re-measure. CSS Anchor Positioning moves all of it into the engine, and this release makes it six props:
<Button anchorName="menu-trigger">Options</Button>
<Box position="absolute" positionAnchor="menu-trigger" positionArea="block-end span-all" positionTryFallbacks="flip-block" mt={2}>
…
</Box>anchorNamenames the element other things can hang off. The--CSS insists on is optional here and added on the way out, the way avarskey's is, so"menu-trigger"and"--menu-trigger"name the same anchor.positionAnchoris the layer's end of that link, besideposition="absolute".positionAreais which cell of the 3×3 grid around the anchor to sit in, and it replaces the side-and-alignment arithmetic entirely. Block axis first, inline axis second — the orderborderRadiusStartStartalready reads in — and the two keywords come from one family, so"block-end center"and"top left"are values while"top inline-start"is not. Offset the layer from its anchor with an ordinary margin.positionTryFallbacksis the flip:flip-block,flip-inline,flip-start, space-combined into one candidate position or comma-separated into a list tried in order.positionTryOrderpicks the roomiest candidate instead of the first that fits, andpositionVisibilityhides the layer once its anchor scrolls out of sight (anchors-visible) or once the layer itself would overflow (no-overflow).
justifySelf and alignSelf also gained anchor-center, which centres a layer on the anchor's other axis.
Two things worth knowing, both measured in Chrome 152 rather than read off the spec. A candidate position has to fit on both axes to be taken, so an overflow the flips cannot fix disqualifies every one of them and the layer silently stays put: with positionArea="block-end center" a layer wider than its anchor overflows the centre column, and flip-block then does nothing at all. Span the axis you are keeping — "block-end span-all" — and it flips. And positionVisibility hides at paint time, so the layer keeps its box and its computed visibility still reads visible; a test that checks this needs a screenshot, not a computed style.
The engine validates all of it, so a value the browser would drop emits no rule and no class: positionArea="top inline-start" mixes two families, positionTryFallbacks="flip-sideways" is not a keyword, and anchorName="2bad" is not an identifier — none of the three reaches CSS.
Support is Chrome 125+, Firefox 147+ and Safari 26+. Where it is missing the layer simply renders unpositioned, so this is the CSS-first half of the story; Overlay still measures, and the shared hook that picks between the two paths is the next step.
One hook places a floating layer, and a length can come off the anchor
The six props above are the placement; this is the hook that writes them for you, and the two value families that make a layer size itself against its trigger with nothing measured.
import { useAnchorPosition } from '@box-kite/react/anchor';
const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true });
<Button {...anchorProps} onClick={toggle}>
Options
</Button>;
{
isOpen && (
<Box {...layerProps} component="menu">
…
</Box>
);
}Spread anchorProps on the trigger and layerProps on the layer, and on a browser with CSS anchor positioning that is the whole of it: nothing runs — no measuring, no scroll listener, no state, nothing to keep in sync when the page moves. side is 'top'/'bottom' (the block axis) or 'start'/'end' (the inline one, 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 spacing scale and lands as the margin on the side facing the anchor, and flip is on by default. Where the browser has no anchor positioning — Safari below 26, Firefox below 147 — the hook measures instead, flipping to the opposite side and shifting along the other axis to stay in the viewport; both paths come out of one model, so the fallback is the CSS placement worked out by hand rather than a second set of rules. css in the result says which one ran.
matchWidth is one line of CSS rather than a measurement, because every sizing prop now takes an anchor-size() value and every single-side inset prop an anchor() one:
<Box position="fixed" positionAnchor="menu-trigger" minWidth="anchor-size(width)" maxHeight="anchor-size(height, 20rem)" />
<Box position="fixed" top="anchor(bottom)" insetStart="anchor(left)" />anchor-size() takes an axis (width, height, block, inline and the self- twins), anchor() an edge (top, right, bottom, left, start, end, center, inside, outside) or a percentage along one. Both take an optional anchor name first — with the same optional -- the props take — and an optional fallback after a comma, and both are validated, so a typo emits no rule rather than a broken declaration. Together they are what lets a layer be placed and sized in a Server Component, with no hook at all.
Three things worth knowing, all measured in Chrome 152:
- The layer is
position: fixed, so it escapes everyoverflow: hiddenancestor with no portal — but not a transformed one, which is a fixed element's containing block either way, and not the page's stacking order.Overlayis still the answer when the layer has to come out on top of everything. - A flip is sticky. Once the browser takes one it keeps it until the layer is laid out afresh, which is what stops it oscillating as the page scrolls. Hiding the layer and showing it again re-evaluates, 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.
- The anchor's name is an inline style rather than a prop. An identity is per instance, so a class for it would be a rule per instance that is never freed — everything shared (the area, the flip, the margin, the width) is an ordinary prop and an ordinary class.
The whole surface is on /anchor, with a live playground for the twelve placements.
Every popup in the library is placed by the browser
The hook above is what the pre-built layers use now. Overlay is useAnchorPosition plus the browser's top layer (a portal, when that release shipped — see below), and Tooltip, the Dropdown popup and the DataGrid column menu are Overlay — so a popup in this library is placed by position-area and flipped by position-try-fallbacks, and on a browser with CSS anchor positioning nothing runs to keep it there: no measurement, no scroll listener, no state.
<Overlay anchor={triggerElement} side="bottom" align="start" offset={2}>
anything, anywhere
</Overlay>;
<Tooltip content="Deletes the row for good" side="end" offset={2}>
{(trigger) => <Button {...trigger}>Delete</Button>}
</Tooltip>;One placement vocabulary, the hook's: side is 'top'/'bottom' on the block axis or 'start'/'end' on the inline one — so a layer beside its anchor mirrors in a right-to-left page — align lines it up with one of the anchor's edges, offset is the gap on the ÷4 spacing scale, and flip lets the browser move a layer that does not fit. anchor is the element to hang off, and matchWidth makes the layer at least as wide as it. The transforms and callbacks these components used to take are gone; the migration is in Breaking changes below.
Three home-grown flip heuristics went with it. The dropdown popup opened upward when its trigger was below the middle of the viewport, the column menu opened leftward when its button was in the right half of it, and the tooltip never flipped at all — none of the three asked whether the layer actually fits. The browser does, and it is right in the cases the heuristics were wrong about: a trigger low on a tall page with room under it keeps its popup below, and one with no room gets it above.
Four things worth knowing, all measured in Chrome 152:
- Escaping a clipped ancestor was never what the portal was for.
position: fixedalready escapes everyoverflow: hiddenancestor; what it does not escape is a transformed ancestor (a fixed element's containing block) or the page's stacking order. The portal was for those two, and the top layer replaced it for both — see Every floating layer is in the top layer. Either way it composes with anchor positioning, because ananchor-nameis not scoped to a subtree. - A flip needs three candidates, not one. A candidate position has to fit on both axes, so a lone
flip-blockdoes nothing at all for a layer that overflows the cross axis — the browser leaves it pressed against the edge of the viewport.flipoffers the side's own axis, the alignment's, and both, in that order, which is what makes a column menu aligned to its button's end mirror to the other end near the edge of the page instead of running off it. - Knowing which side the browser chose costs the entrance.
Overlay'sonSideChangereads the usedposition-area, and that read is the style resolution@starting-stylecomputes its before-change style from — so a class depending on the answer always lands after the entrance has been decided. An exit runs long afterwards and can use it, which is whydropdown.itemskeepsclosedUpand has noup. - A used
position-areais not the value that went in. Aspan-allhalf is dropped (block-end span-allreads backblock-end) and a value naming both axes comes back in thestart/endshorthand, where position names the axis rather than a keyword:block-end span-inline-endreads backend span-end, andstart span-endonce it has flipped.
A popover is a browser feature now, not a portal
Every floating panel in React has been the same pile of workarounds: a portal so it escapes overflow: hidden, a z-index so it lands on top, a click-outside listener, an Escape listener, and code to put focus back. The browser owns all five now, and <Popover> is what that looks like:
import Popover from '@box-kite/react/components/popover';
<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}>
<Checkbox label="Only mine" />
</Popover>;That is the whole thing. The panel carries the popover attribute, so it is in the browser's top layer; the trigger carries popovertarget, so the browser toggles it; light dismiss and focus return come with them. The component supplies what the platform deliberately does not — the role="dialog", the name, aria-expanded/aria-haspopup/aria-controls on the trigger, moving focus into the panel on open, and onOpenChange(open, { reason }).
There is no portal, and that is the point. A top-layer element paints above every stacking context and outside every clipped ancestor — measured in Chrome 152 against a sibling with z-index: 9999, a transformed ancestor and an overflow: hidden one, where position: fixed loses two of the three. Because nothing is moved, the panel keeps what it inherits where it was written: the theme around it, the custom properties, the text direction, and its place in the tab order — trigger, then panel, then the rest of the page. A portalled layer has none of that for free.
It is also small. <Popover> adds 3.46 KB gz on top of a Box an app already has, and 1.03 KB where Tooltip or Dropdown already ship the placement and behaviour chunks it shares. Measured, minified and gzipped.
Three things worth knowing, all measured rather than read off the spec:
- The panel is always rendered; closed is
display: none, not unmounted. That is what lets the browser own showing and hiding — and it is what makes the exit a plain CSS transition rather than a<Presence>, since nothing leaves the DOM to be held back. The entrance isstartingStyle, the exit istransitionBehavior="allow-discrete", and both are already in the component's styles. Children that are expensive to build should be gated by the consumer:{open ? <Heavy /> : null}. - A close cannot be refused. The platform's
beforetoggleis cancelable opening and not cancelable closing, so a controlled<Popover open>can decline to open but hears about a light dismiss only after it has happened. Keepopentrue and the component shows the panel again rather than arguing with the browser. - The trigger has to be a button, because
popovertargetis what the browser reads off one — and handing the toggle over is what fixes the trap every hand-rolled popover falls into. Light dismiss closes onpointerdown, so a click handler of your own runs afterwards, reads "closed" and opens the panel straight back up; pressing the trigger of an open popover would never close it.
Where the browser has no Popover API the panel falls back to an Overlay — a portal — with useDismiss and useFocusReturn supplying what the platform would have. The props are identical and so is the styling; what is lost is what the portal costs. Both paths are on /popover.
Every floating layer is in the top layer, and the portal is gone
<Popover> above needs no portal because the browser's top layer replaces one. The same is now true of every other layer the library ships: Overlay renders where it is declared and carries popover="manual", and Tooltip, the Dropdown popup and the DataGrid column menu are Overlay — so nothing in the library portals any more.
<Box.Theme use="local" theme="dark">
<Dropdown<string> label="Fruit">
<Dropdown.Item value="a">Apple</Dropdown.Item>
</Dropdown>
</Box.Theme>The popup is dark now. That is the change in one example: a layer that stays in the DOM it was written in inherits — the theme around it, the custom properties, the text direction — where a portalled one hung off the end of <body> and inherited nothing, which is why Overlay used to measure the direction off its anchor and copy it back on as a dir.
manual rather than auto is the deliberate part. Light dismiss is a pattern, and Overlay implements none: <Popover> is the auto one. The three components built on it each own a dismissal already, and each is stricter than the platform's — a tooltip's Escape has to last, which WCAG 1.4.13 asks for and light dismiss does not do, and a dropdown's trigger toggles in JavaScript, which light dismiss fights for the reason the <Popover> section describes.
Four things worth knowing, all measured in Chrome 152:
- The top layer beats what
position: fixedcannot. A layer in it paints over az-index: 9999sibling and out of atransformed ancestor; a plain fixed layer at the same coordinates is covered by both. That is what the portal was for, and it is no longer what the portal is needed for. - A press inside a layer is a press inside whatever popover it was declared in. So a
Dropdowninside aPopoverpanel no longer dismisses the panel when you open its popup or choose an option — the bug a portal caused by moving the popup to the end of the body, where every press in it read as a press outside the panel. Nothing was configured to fix it; DOM containment is simply real again. - The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next, and nothing has to be said with
aria-owns. - Mounting is unchanged.
<Presence>still owns it, so a closed dropdown renders none of its options and a closed tooltip renders nothing at all — and an exit transition runs in the top layer like anywhere else. This is the opposite trade from<Popover>, whose panel is always rendered because the browser owns its toggle.
One thing the top layer costs, and it is worth knowing before you reach for a layer: it keeps the side it chose when it opened. Chrome re-evaluates position-try-fallbacks on scroll for an ordinary positioned element and never for one in the top layer, so a layer left open while the page scrolls does not flip when its side runs out of room — it slides past the viewport edge instead. Measured in Chrome 152, both with the library and in hand-written CSS against an otherwise identical element outside the top layer, which does flip.
Every open picks the right side, because a layer that mounts when it opens is laid out for the first time then, and that is the common case: a dropdown opened near the bottom of the window still opens upwards. Only a scroll while the layer is open is affected. Nothing but leaving and re-entering the top layer re-arms the browser, so close a layer if the page can scroll far underneath it — and note that <Popover> shares this, since its panel is in the same top layer, while the no-Popover-API fallback measures and therefore flips.
Where the browser has no Popover API the layer is portalled into #box-kite-portal exactly as before, with everything a portal costs. The one thing to check when upgrading is in Breaking changes below: a layer must not be declared inside its trigger any more.
A modal dialog is a browser feature too
<Dialog> is a real <dialog> shown with showModal(), and that one call is the pattern: the top layer, the ::backdrop, an inert page behind it, Escape, focus containment and focus return are the browser's. There is no focus trap in this component, no aria-hidden sweep over the page, and no z-index anywhere.
import Dialog, { AlertDialog } from '@box-kite/react/components/dialog';
<Dialog trigger={(t) => <Button {...t}>Rename</Button>}>
<Dialog.Title>Rename this view</Dialog.Title>
<Dialog.Description>The name is only shown to you.</Dialog.Description>
<Textbox name="name" props={{ 'aria-label': 'Name' }} />
</Dialog>;Rendering a Dialog.Title is what names the dialog. It writes aria-labelledby, a Dialog.Description writes aria-describedby, and neither attribute exists when the part is absent — so a name and a visible heading cannot drift apart, and a reference never points at nothing. A dialog that shows no heading takes label, labelledBy or describedBy instead.
What the component adds on top of the element: role="dialog", the naming above, aria-haspopup="dialog"/aria-expanded/aria-controls on the trigger, open/defaultOpen/onOpenChange(open, { reason }) — trigger, escape, outside-pointer or imperative, the last covering a close() call and a <form method="dialog"> submit alike — modal (false is show(): no backdrop, nothing inert), dismissible, lockScroll, and initialFocus.
<AlertDialog>, a named export of the same module, is the interrupting kind:
const cancel = useRef(null);
<AlertDialog initialFocus={cancel} trigger={(t) => <Button {...t}>Delete</Button>}>
<AlertDialog.Title>Delete this view?</AlertDialog.Title>
<AlertDialog.Description>Nothing here can be undone.</AlertDialog.Description>
<Button bgColor="rose-600">Delete</Button>
<Button ref={cancel}>Cancel</Button>
</AlertDialog>;role="alertdialog" tells a screen reader the content is an alert rather than a panel, so the name and the description are announced together the moment it opens. It is always modal and never dismissed by a press outside — a decision that can be clicked away is one the user did not make — while Escape still closes it, because a keyboard user must always have a way out. initialFocus is APG's requirement that focus land on the least destructive action, so a deletion cannot be confirmed by reflex.
Four things worth knowing, all measured in Chrome 152:
- The dialog is always rendered, and closed is
display: nonerather than unmounted — the same shape as<Popover>, and what lets the browser own showing and hiding. The exit is therefore a CSS transition (transitionBehavior="allow-discrete", already in the component styles, and it covers the::backdroptoo) and never a<Presence>. Gate expensive children yourself with{open ? <Heavy /> : null}. - A close cannot be refused. The
cancelevent is cancelable, but the browser has already closed the dialog by the timeonOpenChangeruns, so a controlled<Dialog open>hears about a dismissal afterwards and keepingopentrue shows it again.dismissible={false}is how a decision is made unavoidable. - For a modal dialog the backdrop is the dialog element. A press on it targets the
<dialog>, which is why every hand-rolled outside-press check calls a backdrop click "inside". The platform's ownclosedby="any"gets it right and the component writes it; where a browser has not got that yet, the press is measured against the dialog's own box instead. - The platform does not stop the page scrolling —
overflowon<html>staysvisiblewhile a modal dialog is open, and a wheel over the backdrop scrolls the page behind it. That is the whole reasonlockScrollexists, and it defaults to whatevermodalis rather than being unconditional: it is anoverflow: hiddenclass held by a counter, so an inner dialog closing cannot unlock the page under an outer one. The scrollbar's width leaves the page as the lock is applied, so a document that must not shift wantsscrollbarGutter="stable".
The style tree in Box.components('dialog') deliberately says almost nothing about position or size: the browser's own stylesheet centres a modal dialog in the viewport and caps it at calc(100% - 6px - 2em), which is better than any default here could express. The one exception is margin: auto, which it has to declare because every Box carries margin: 0 and an author rule outranks the UA's — without it a modal dialog sits in the corner instead of the middle. display: none while closed is declared for exactly the same reason.
It is also small: <Dialog> and <AlertDialog> together add 2.15 KB gz on top of a Box an app already has, because the pattern is the browser's. Measured, minified and gzipped.
Because nothing is portalled, a local Box.Theme reaches inside a dialog the way it now reaches inside a dropdown: a top-layer element still matches the .dark .className rules of the ancestor it was declared in, and still inherits its custom properties and its text direction. Measured both with the library and in hand-written CSS.
A menu, and its submenus, are nested popovers
<Menu> (components/menu) is APG's menu button on the same Popover API <Popover> and <Dialog> stand on — and a submenu is a popover nested inside its menu, which is what the platform's own nesting rules are for.
import Menu from '@box-kite/react/components/menu';
<Menu trigger={(t) => <Button {...t}>Actions</Button>}>
<Menu.Item onSelect={duplicate}>Duplicate</Menu.Item>
<Menu.Item disabled>Move</Menu.Item>
<Menu.Separator />
<Menu.Group label="View">
<Menu.CheckboxItem checked={compact} onCheckedChange={setCompact}>
Compact rows
</Menu.CheckboxItem>
</Menu.Group>
<Menu.RadioGroup label="Sort by" value={sort} onValueChange={setSort}>
<Menu.RadioItem value="name">Name</Menu.RadioItem>
<Menu.RadioItem value="date">Date added</Menu.RadioItem>
</Menu.RadioGroup>
<Menu.Sub label="Share">
<Menu.Item onSelect={copyLink}>Copy link</Menu.Item>
</Menu.Sub>
</Menu>;The platform's half is the layer. The menu is in the top layer, so it paints over every stacking context and outside every clipped ancestor with no portal and no z-index; light dismiss is the browser's, and it closes the innermost menu first — one layer per Escape, a submenu before the menu it came out of — while a press outside closes the lot. The trigger's toggle is popovertarget, including the press that closes an open menu from its own button. All of it measured in Chrome 152.
The component's half is the pattern. role="menu" named by its trigger (APG's rule, so a menu needs no label of its own), menuitem, menuitemcheckbox and menuitemradio with aria-checked, role="group" around a titled section and role="separator" between them. Then the keys: Down and Up wrapping at the ends, Home and End, typeahead where a longer buffer narrows and the same letter again cycles, and Right and Left for a submenu — in reading order, so the two swap in a right-to-left menu and the chevron turns round with them from a single rtl rule.
Menu.Sub renders both halves of a submenu: the item that opens it — aria-haspopup="menu", aria-expanded, the chevron — and the menu beside it, declared inside the menu it belongs to. It takes the same four placement props as the menu (side/align/offset/flip), defaulting to side="end" and offset={0} so it abuts the menu it came out of, and submenus nest as deeply as the markup does.
Four things worth knowing, each of them measured:
- A disabled item is
aria-disabledand stays focusable. That is APG's rule, and thedisabledattribute would break it: an item out of the keyboard's reach is an item a keyboard user cannot discover. The arrows land on it, a screen reader announces it as unavailable, and activating it does nothing. - A command closes the menu and a state does not.
Menu.Itemcloses on select, because choosing a command is the end of the visit;Menu.CheckboxItemandMenu.RadioItemstay open, so several boxes can be ticked in one go.closeOnSelectswaps either default.onOpenChangereportsselectandtabbeside the four reasons every layer in the library reports, so "the user chose something" and "the user dismissed it" are told apart without guessing. - The menu is always rendered, closed being
display: nonerather than unmounted — the same shape as<Popover>and<Dialog>, and what lets the browser own showing and hiding. The exit is a CSS transition rather than a<Presence>; gate expensive items yourself with{open ? <Items /> : null}. - The platform returns focus for the outermost layer only. Closing a nested popover drops focus to
<body>— so a submenu puts focus back on its own item itself, before the browser hides the panel, and the move never lands anywhere visible. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in the same place.
It is small: <Menu> and all seven of its parts add 6.36 KB gz on top of a Box an app already has. Measured, minified and gzipped.
The four marks a menu draws — the tick, the radio dot, the chevron and the slot they sit in — are borders and a radius rather than an asset, so the library still ships no icons, and each is a node in the style tree (menu.item, menu.group, menu.label, menu.separator, menu.indicator, menu.check, menu.dot, menu.arrow). The highlight is drawn on :focus, because in a menu focus is the highlight: the pointer moves it, so hover and the keyboard cannot disagree.
One name had to move out of the way. The semantic <menu> element is MenuList in components/semantics now; Menu is still exported there and still works, but it is deprecated — two exports of that name are one import away from the wrong component.
Tabs, where selection follows focus
<Tabs> is APG's tabs pattern: one list of tabs over one panel at a time, with four parts, a fifth for when the panels should resize, and nothing to wire up.
import Tabs from '@box-kite/react/components/tabs';
<Tabs defaultValue="overview" onValueChange={(value, { reason }) => log(value, reason)}>
<Tabs.List label="Project">
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab value="audit" disabled>
Audit log
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
<Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
</Tabs>;Selection follows focus, which is APG's default and what a reader expects: one arrow key moves to a tab and shows its panel. activation="manual" splits the two — 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 the pattern's whole surface, and all of it is here. Tab enters the list once, landing on the selected tab, and again leaves it for the panel: one tab stop for the list however many tabs it holds, and the panel carries tabindex="0" so its content is reachable whether or not anything inside it is focusable. Right and Left move in a horizontal list, Down and Up in a vertical one, both wrapping at the ends unless loop={false}, and Home and End go to the first and last tab. The off-axis pair is deliberately left to the page, so a horizontal tablist does not eat a scroll.
The arrows follow the reading order: in a right-to-left page ArrowLeft is the next tab, read off the element's own resolved direction rather than an ancestor's dir attribute. A vertical list moves its indicator to the inline end for the same reason — be, not br, so it mirrors with the reading order instead of staying on the right.
Four things worth knowing:
- A disabled tab is not selectable, and the arrows step over it. That is the opposite of
Menu.Item, which APG asks stay reachable, and the difference is what selection following focus costs: a tab focus could land on but selection could not would leave the widget with no state to be in. - Only the selected panel is rendered. An unmounted panel costs nothing and gets an entrance for free, since
startingStyleruns on the mount — but it also loses whatever state it held, sokeepMountedrenders them all and hides the rest, which is what a panel holding a half-filled form wants. A hidden panel carries thehiddenattribute and adisplay: nonerule of its own, because every Box carriesdisplay: blockand any author rule outranks the UA's[hidden]one. - The tabs are read off the DOM, not out of a registry. A tab a consumer wrapped in a layout of their own, rendered from a list or put behind a condition is in the order it was written and navigates like any other — and a nested set of tabs belongs to its own list rather than the one around it.
- The tab sequence follows the selection. Change a controlled
valuefrom somewhere else on the page and the keyboard's entry point moves with it, so Tab never enters the list at a tab whose panel is not the one on screen. Underactivation="manual"the tab sequence follows the focus instead, which is what APG's own example does: arrow away, Tab out and back, and you return to the tab you had reached.
onValueChange reports the new tab and why it changed — 'click' or 'keyboard' — and the keyboard reason survives Enter and Space in manual activation, because the handler gets in front of the click a <button> would synthesize from the key.
Two things move, and both are opt-in. indicator="sliding" replaces the border each tab draws with 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 a switch between panels of different heights is a transition rather than a jump.
<Tabs defaultValue="summary" indicator="sliding">
<Tabs.List label="Release">
<Tabs.Tab value="summary">Summary</Tabs.Tab>
<Tabs.Tab value="changelog">Changelog</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel value="summary">One line, and the box is one line tall.</Tabs.Panel>
<Tabs.Panel value="changelog">Four, and it grows to fit them.</Tabs.Panel>
</Tabs.Panels>
</Tabs>Both are measured, which has three consequences worth knowing. The travelling indicator appears once the widget has run, so until then — every prerendered page, and any reader whose JavaScript never arrives — the tabs keep drawing their own border; the two sit in exactly the same place, so the handover is invisible and a static page is never left with no indicator at all. The panel container watches the panel and never itself, because its own height is what it writes: watching that would be the loop, and watching the panel catches a reflow — a line that wraps, a font that lands — as well as a switch. And both ride --transitionTime, so under prefers-reduced-motion each computes to 0s and the bar and the box land on their new values in the same frame, with no opt-out to remember.
The container is clipped only while its height is travelling. It has to be: a panel already at its full height inside a container still on the way there paints over whatever follows the tabs. But at rest the container is exactly as tall as its panel, so a permanent clip would cut the focus ring off every element sitting at that edge — and it would be a clip on both axes whatever was asked for, since overflow computes a visible companion to a clipped axis up to auto.
It is small: <Tabs> and all four of its parts add 3.03 KB gz on top of a Box an app already has, measured, minified and gzipped. About 1.16 KB of that is the two things that move, and it is there whether or not they are switched on: Tabs.Panels hangs off Tabs and the list measures from the same module, so neither shakes out.
Every part is a Box, and the defaults are a style tree — tabs, with tabs.list, tabs.tab, tabs.panel beneath it, plus tabs.indicator for the travelling bar and tabs.panels for the resizing container. The selected state is aria-selected, so ariaAttr={{ selected: … }} styles it on a tab — inside a style tree the same state is the bare selected key, while as a prop selected writes the attribute the component owns. The indicator is a border rather than a background, because a forced-colors mode throws every background away and selection would otherwise read identically on and off.
An accordion whose animation is a class
<Accordion> is APG's accordion — a set of sections, each opened by its own header — and Collapsible, a named export of the same module, is one disclosure on its own.
import Accordion, { Collapsible } from '@box-kite/react/components/accordion';
<Accordion defaultValue={['shipping']} onValueChange={(value, { reason }) => log(value, reason)}>
<Accordion.Item value="shipping">
<Accordion.Trigger>Shipping</Accordion.Trigger>
<Accordion.Panel>Two to four working days, tracked.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Trigger>Returns</Accordion.Trigger>
<Accordion.Panel>Thirty days, in the packaging it came in.</Accordion.Panel>
</Accordion.Item>
</Accordion>;The height animation is a class, not a measurement. Every other library measures: an effect reads the panel's height, writes it into a custom property, and a ResizeObserver keeps it up to date as the content changes. Nothing here does. The panel sits in a grid of one row whose track runs 1fr to 0fr, so the height being animated is the one the browser was going to compute anyway. Three things follow. There is no number to write down, so there is nothing per instance — two shared rules do the whole animation on every accordion on the page, where a measured height is a rule per panel that is never freed. Content that grows while the panel is open simply makes it taller, because nothing was pinned. And a page animates before its JavaScript arrives, since none of it is JavaScript.
A closed panel is hidden with visibility, not display. It has to be hidden by something: content behind a zero-height track is still laid out, and clipped content is still tabbable and still read out. visibility is the one that is animatable — it flips to hidden only once the track has finished closing, and back to visible the instant it opens, which is exactly the two moments the content may not be reachable. That is also why there is no @starting-style here and no transitionBehavior="allow-discrete": unlike display, visibility leaves the element with a before-change style to transition from, so the entrance needs nothing declared and a server-rendered open panel does not animate itself open on load. It rides --transitionTime like everything else, so prefers-reduced-motion turns both directions off with no opt-in.
The consequence to know is the other side of it: the panel is always in the DOM, so a half-filled form in one survives being shut — and children too expensive to render closed are yours to gate, with {open ? … : null} inside the panel.
Every header is its own tab stop, which is the opposite of Tabs and is APG's rule for each: an accordion is not a composite widget, so Down and Up are a shortcut between headers rather than the only way in. They wrap unless loop={false}, Home and End go to the ends, and the sideways pair is left to the page. Each header is a real <button> inside a heading — level, default 3, says which, because a heading level is the document's outline and a screen reader navigates by it. The panel is a role="region" named by its header; past half a dozen of them the landmarks are noise, which is APG's own caveat, and props={{ role: undefined }} drops it.
One panel at a time unless multiple says otherwise, and closing the open one is always allowed — so an accordion can stand with everything shut, and there is no second prop to say whether it may. A disabled section is the disabled attribute, so the browser has already taken it out of the tab sequence and the arrows step over it for the same reason.
Collapsible is the same mechanism with no heading, no group and no arrow keys, because a lone button needs none of them. Note the shape — the trigger is the render prop and the children are the content, the way <Popover> reads:
<Collapsible trigger={(trigger) => <Button {...trigger}>What is in the box?</Button>}>
<P>A kite, and the string for it.</P>
</Collapsible>It is small: <Accordion>, all three of its parts and Collapsible add 1.80 KB gz on top of a Box an app already has. Measured by a harness that ships in the repo — npm run size:published, so the number in this paragraph can be re-derived rather than believed. Measuring nothing is most of what a panel animation usually costs.
Every part is a Box, and the defaults are a style tree — accordion, with accordion.item, accordion.heading, accordion.trigger, accordion.arrow and accordion.panel beneath it, plus collapsible for the lone one. The open state on a header is its own aria-expanded, so ariaAttr={{ expanded: … }} styles it and no variant is needed: the attribute the pattern already has to write is the selector. Two parts are not yours: accordion.track, the grid whose row animates, and accordion.clip inside it, the bare item that clips. Bare is the point — padding cannot be squeezed, so a grid item carrying any floors the 0fr track at exactly that much, and the panel you pad therefore sits inside the clip rather than being it. The panel's own padding is also the room a focus ring at its edge needs, since the clip is permanent.
A slider whose value keeps its own shape
<Slider> is APG's slider and its multi-thumb sibling in one component, and <Progress> is the bar beside it. 2.14 KB gz and 0.31 KB gz on top of Box — 2.31 for both together, measured the same way.
import Slider from '@box-kite/react/components/slider';
import Progress from '@box-kite/react/components/progress';
<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />;
<Slider label="Price" defaultValue={[20, 80]} thumbLabels={['Lowest', 'Highest']} format={(value) => `${value} lei`} />;
<Progress label="Upload" value={62} />;A number in is a number out
The number of thumbs is the value's own shape. defaultValue={40} is one thumb whose onValueChange hands back a number; defaultValue={[20, 80]} is a range whose handler takes a number[]. TypeScript infers which from the value you wrote, so there is no second prop saying which kind of slider this is and nothing to narrow at the call site — where a library that always takes an array makes every single-value slider read value={[volume]} and onValueChange={([v]) => …}.
A thumb may meet the one beside it and never pass it: crossing would renumber the thumbs under the focus that is on one of them. Three and more work the same way, each held between its neighbours.
onValueChange fires on every step of a drag, which is what a live preview wants and what a network request does not; onValueCommit fires once, when the pointer is let go or the key comes back up. Both report { reason: 'pointer' | 'keyboard' } beside the event.
Neither is the native element, and that is the one place the platform loses
Every other control in this library is a real form element with a role over it — a Switch is a checkbox, a RadioGroup is a set of radios, a Dialog is a <dialog>. A slider is where that stops. <input type="range"> cannot hold two thumbs, and it and <progress> both draw themselves with vendor pseudo-elements — ::-webkit-slider-thumb, ::-moz-range-track — that no typed prop can reach, so a styled one is a rewrite of every part and the native element is left supplying only the keyboard.
The half worth keeping is kept: name writes one hidden input per thumb, so a range posts two values under that name and a plain FormData reads them.
The position is an inline style, and it is the only thing that is not a class
Everything a slider paints is a shared rule except where its thumbs are. That one value is per frame of a drag, so a class for it would be a rule per frame — written into the stylesheet and never freed. So it is an inline inset-inline-start, the exception useAnchorPosition's anchor name and the travelling Tabs indicator already take.
It is also the line between these two and ProgressRing from components/chart, which rounds its fraction to half a percent and puts it in a shared class: nobody drags a ring. Round a thumb that far and a wide track visibly stair-steps under the pointer. A dashboard of a hundred figures still wants the ring.
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. Every other move — a drag, an arrow key, a held arrow — takes a short 60ms linear travel on both the thumb and the fill instead, so they keep up and stay locked together.
Short rather than off, because off is exact and steps. A value on a grid can only ever be at its grid positions, so a slider of 1 in 100 moves 3.2px at a time on a 320px track: nothing is dropped and nothing lags, and it still reads as jumpy next to a press that eases.
Eased out, which is neither linear nor eased. A repeat restarts the transition every 33ms, so only its first half is ever seen — and on ease-out that half is the straight part, so the thumb glides while the key is held and still decelerates on the last transition, the one allowed to finish. Plain ease replays its slow-in on every repeat and pulses: a 2.7× swing in speed inside each step, against 1.6×. linear is the smoothest of the three while held (1.15×) and loses on the other two counts — it rides 1.4 steps behind the value where ease-out rides 0.9, and it arrives at full speed where ease-out lands at 0.6 of cruising. A 33ms wobble is below what the eye resolves as a change of speed; a thumb a whole step stale and a dead stop are not.
And an arrow key is a nudge, not a jump, so it never takes the press travel at all: one tap spent 250ms crossing 3.2px, which is the lag it read as. A tap now arrives in 60ms.
One thing to know when you reach for value rather than defaultValue: a controlled slider re-renders whatever component owns its useState, thirty times a second while an arrow is held, so keep the value next to the slider rather than at the top of a page. The slider itself costs 0.3ms a keystroke; this documentation page was holding three demo values in the page component, and a single arrow key re-rendered all eight sliders, every code block and the API table — 4.2ms a keystroke and 32ms to paint, against 0.8ms and one frame once each demo held its own.
Both parts have to carry it or they come apart, which is the other half: every Box has a 250ms all transition, so a fill left with the default eased towards a thumb that had already arrived — up to 59px behind it mid-drag, and still moving for ~190ms after the pointer stopped.
The real dial is step, which is what a slider can be smoother than: step={0} is continuous, and the thumb then follows the pointer with nothing to interpolate. The smoothing is one variant, tracking, on slider.fill and slider.thumb.
It mirrors for free
The fill and the thumbs are placed with inset-inline-start and centred with a logical margin, so a right-to-left page draws the minimum on the right with nothing declared twice and no re-render. The half that is not free is the keyboard: the sideways arrows swap — ArrowLeft is the increase when the maximum is on the left — and ArrowUp never does, because the block axis has no reading order. The direction is read off the element when the key arrives, so a slider inside a dir="auto" subtree behaves the way it looks.
Both arrow pairs work on both orientations, PageUp/PageDown move by largeStep, and Home/End go to the ends themselves — on the step grid or off it. Every thumb is its own tab stop.
No value on a Progress is a state, not a zero
Leave value out and the bar is indeterminate: aria-valuenow is omitted entirely rather than written as 0, because a position nobody measured is the one thing a reader must not be told. The bar sweeps instead, and the sweep names its duration in milliseconds, so it sits outside the --transitionTime that prefers-reduced-motion zeroes and stops itself.
Progress holds no state, runs no effect and measures nothing, so it renders in a Server Component — a page paints a real figure before any JavaScript arrives.
Naming, and the parts
A role="slider" has to be named. label names the thumb on a single slider; on a range it names the role="group" around the thumbs and thumbLabels names them one at a time. format writes aria-valuetext wherever the bare number is not the value — a currency, a rating, a duration. disabled is aria-disabled rather than the attribute: a value nobody can reach is a value nobody can read, and a <div> takes no disabled anyway.
Styling is slider, slider.track, slider.fill and slider.thumb, every part carrying the vertical variant, plus progress and progress.fill, whose indeterminate variant is the sweep.
Box.components({
slider: {
children: {
fill: { styles: { bgGradient: { linear: 'r', colors: ['sky-400', 'indigo-500'] } } },
thumb: { styles: { borderColor: 'sky-500' } },
},
},
});A message you send rather than render
<Toaster /> goes in once, near the root of the app. Everything after that is toast(), from wherever the message actually comes from.
import Toaster, { toast } from '@box-kite/react/components/toaster';
<Toaster position="bottom-end" limit={3} />;
// an event handler, a fetch, a router guard, a module with no React in it
toast.success('Saved');
toast('Row deleted', { description: 'Ada Lovelace, added in March.', action: { label: 'Undo', onClick: restore } });
toast.promise(save(), { loading: 'Saving…', success: (saved) => `Saved as ${saved.name}`, error: 'Could not save' });4.14 KB gz on top of Box, styled — no stylesheet to import, because the appearance is in the JavaScript already counted. Measured as what it adds to an app that already has Box. The style tree behind it also costs 0.50 KB gz on every entry that carries the engine, whether or not the app renders a toast: the built-in component styles ship with Box, and this is the largest of them.
The store has no React in it
toast() writes to a plain observable store; <Toaster> subscribes and draws what is there. Nothing about a message is a rendering question, so nothing about it needs a component in scope — and a call made before the viewport mounts is queued rather than lost.
toast(message, options) returns an id, with success, error, warning, info and loading beside it, plus toast.update(id, …) and toast.dismiss(id?). An options.id that is already on screen is an update, which is what makes toast.promise one toast for the whole call rather than a spinner and then a second message under it. The promise is handed back untouched, so it can still be awaited.
A message is any ReactNode, and so is a description. action is one button, { label, onClick }, and it dismisses the toast it answered unless closeOnClick: false says otherwise — an undo that left its own toast standing would invite a second press.
The limit is a queue, not a cap
Past limit (three, by default) a toast waits its turn with its timer unstarted, and a counter at the far end of the stack says how many are still to come. A cap would throw the fifth message away; a queue with running timers would expire it unread. Neither is what somebody who fired five requests at once meant.
The region was already there
The viewport is a <section aria-live="polite"> from the moment it mounts, empty — because a live region inserted together with its content is not reliably announced, which is the single most common way a toast system is silent to a screen reader. An error toast is role="alert", assertive, and the one announcement pattern every screen reader implements. Nothing else carries a region of its own: the nearest live region to a change is the one that speaks, so a second inside the first would take the first's head start away rather than adding anything.
A toast never takes focus when it arrives — an announcement is not an interruption. F6 (or hotkey, which takes 'alt+t' and the like, or false) moves focus to the stack from anywhere on the page, Tab walks the toasts in the order they are on screen, and Escape dismisses the toast focus is in and hands focus back where it came from once there is nothing left to read. The <section> is a region landmark too, so a screen reader has a second way in.
Timers stop while the pointer is over the stack, while anything in it has focus, and while the tab is in the background — and resume where they left off rather than starting again. That is WCAG 2.2.1, and it is the only reason a toast is allowed to carry a control at all: a button that can vanish mid-reach is not operable.
The top layer, and no portal
The viewport carries popover="manual" and is shown the moment it mounts, so it paints over every stacking context and outside every clipped or transformed ancestor — and because it never leaves the place it was declared, it inherits the theme, the custom properties and the text direction around it. manual rather than auto, because a stack of messages owns no dismissal: a press outside has to reach the page.
Which raises the problem a fixed strip across a corner always raises, and it was measured rather than assumed: the viewport takes no pointer events at all and the toasts take them back, so a press in the gaps between them goes through to whatever is underneath.
Two more things the UA stylesheet's own [popover] rule decides, both found in Chrome 153 and worth knowing before overriding the style tree. inset: 0 leaves a corner-pinned stack over-constrained — top and left win over the two sides a position variant sets, and the stack lands in the top-left corner whatever you asked for — so all four sides are declared auto first. And overflow: auto quietly makes the viewport a scroll container, which clips the toasts' own shadows, so it is declared visible.
position is one of six corners and its inline half is logical, so 'bottom-start' is the bottom left of a left-to-right page and the bottom right of a right-to-left one. The newest toast is always the one nearest the screen edge, which is why a stack pinned to the top is drawn in the opposite order to one pinned to the bottom.
A combobox whose value is your own row
<Combobox> is APG's editable combobox over a list you already have — a text field that filters a role="listbox", one tab stop however many options are open, and DOM focus that never leaves the input.
import Combobox from '@box-kite/react/components/combobox';
<Combobox data={people} def={{ label: 'name', key: 'id' }} label="Assignee" onValueChange={(person) => assign(person)} />;A row in is a row out. data is the list of objects you already hold, and the value is one of those rows rather than a string dug out of one — onValueChange hands the object straight back, typed, so there is no lookup table on the other side of the handler. def says how to read a row: label is its text (searched, displayed and read out) and key is what makes two rows the same row, so a list refetched from the server still shows the selection as chosen. Both take a key of the row or a function, and disabled and display are the other two.
multiple turns the selection into chips and the value into an array. It is a prop rather than something inferred from the value, which is the one place this differs from <Slider>: a slider always has a value to read the shape off, and a combobox usually starts with nothing in it. Choosing a row that is already chosen takes it off again.
A chip's remove button is deliberately not a tab stop — twenty selections would otherwise cost twenty presses to Tab past. Backspace on an empty field removes the last one and the listbox toggles any row back off, so removal is reachable from the keyboard without them, and each button still carries a name for anyone reading the control rather than tabbing through it.
The filter composes. The built-in one folds case and strips accents, so jose finds José, and matches anywhere in the label rather than only at the front. filter replaces it and takes the whole list, so it can rank as well as reject — and it is handed the label reader, so starting from the built-in one costs nothing:
<Combobox
data={people}
def={{ label: 'name', key: 'id' }}
label="Assignee"
filter={(rows, query, labelOf) => ComboboxUtils.filterRows(rows, query, labelOf).slice(0, 20)}
/>filter={false} says the data arrived filtered, which is what a server that already searched needs; onQueryChange is the hook that request hangs off, and loading makes the popup say the rows are coming rather than that there are none — the difference between "still looking" and "nothing here".
createRow turns what was typed into a row. Return the row, or null to refuse the query. Its presence is what offers the create row at all, and one is never offered for a query a row already answers by name — offering "Create Design" beside Design is how a list grows twins. The change arrives with its own reason, create, beside select, deselect and remove.
Typing filters and never highlights a suggestion: that is list autocomplete rather than inline, and a highlight nobody asked for is one Tab away from being committed. The arrows move through what the filter left, Home/End and the sideways arrows move the caret and hand the highlight back to the field, Enter chooses the highlighted row and does nothing when there is none, and Escape closes the listbox keeping what was typed before a second one clears the field.
Ten thousand options
A list past a hundred rows is windowed: the popup renders the dozen on screen and a few either side, so it opens in one frame whether it holds a hundred rows or ten thousand. Measured in Chrome on the ten-thousand-row demo — fifteen options in the DOM, 49 ms from the press to the open listbox, and sixty consecutive frames of scrolling with none of them over the frame budget. Nothing else about the component changes: the filter still runs over the whole list, the arrows still walk all of it, and the value is still the row you passed in.
The row height is measured rather than declared — the pitch, taken from two rendered rows, so a gap or a border between them is part of the step — which means a restyled option windows correctly with nothing to configure. What windowing assumes is that rows are all the same height, so a display that varies one wants virtualize={false}, which renders every row however many there are. virtualize also takes true to window a short list, and an object to tune it: threshold is the row count it starts at, overscan how many rows are kept either side, and itemHeight the pitch, when it should be stated rather than measured.
The half of this that is not performance is the ARIA. A windowed listbox holds a slice of its rows, so every option carries aria-setsize and aria-posinset — without them a screen reader announces "City 1 of 15" on a list of ten thousand. And the row the keyboard is on is always rendered, whatever the scroll position says: aria-activedescendant naming a row that was never put in the DOM names nothing at all, so the window goes where the keyboard is and lets the scroll catch up. It does not stay pinned there — a wheel scroll away from the highlight moves the list, or a combobox opened on its first row could never be scrolled at all.
combobox.window is the one new style key, the element the slice is rendered into. Its height and its top padding are inline styles rather than classes, because they change with every scroll event and a class per pixel is a rule per frame that is never freed — the same exception a slider's thumb and an anchor's name already take.
9.53 KB gz on top of Box — the model, the listbox, the chips, the windowing and the APG keyboard, on top of the anchoring and dismissal every other layer in the library already shares. A combobox is usually assembled instead — a headless behaviour package, and then the markup, the styling and the ARIA written around it.
The component contract, written down and enforced
Twenty components have arrived in this release, and the thing you cannot see from any one of them is
that their APIs were decisions rather than accidents. The component
contract is those
decisions, in five rules — and npm run check:conventions is what stops the library drifting out of
them.
What a consumer can now rely on. Every piece of state you may own is held by
useControllableState, so controlling a value behaves exactly like not controlling it — including
when you stop, which carries on from the last value you asked for rather than snapping back to the
default. Every change is onXChange(value, { reason }) with the reason a named union you can
switch on exhaustively, so no handler has to guess whether a popup closed because something was
picked, Escape was pressed or the page was clicked. Every part takes Box style props and every
component has a node in boxComponents.ts, so <Menu.Item px={3}> works and the defaults can be
replaced wholesale. And composition is a render prop — the answer to asChild: a component
hands you a bag to spread and never reaches into the element you rendered, because cloning has to
guess whether a DOM attribute belongs in a Box's props bag or on top of a plain element, and a
wrong guess is silent.
The exceptions are the interesting part, so they are a ledger rather than a habit. The biggest
is deliberate: a component rendering a real form control — Checkbox, Switch, RadioButton,
Textbox, Textarea, Button — forwards React's own onChange/onInput/onClick unchanged,
because the DOM event is the API there and a second channel beside it would mean two ways to hear
about one keystroke. Menu.Item's onSelect is a command with no value to report, Overlay's
onSideChange reports what the browser did, and Icon is the one component that clones its child —
it styles an icon somebody else drew, so there is no render prop to offer.
Every ledger in the check fails two ways: on a new break, and on a listed exception that has
stopped being true. That is the rule the accessibility sweep's knownViolations already follows,
and it is what keeps an exception list from becoming a place things go to be forgotten. Its list of
debts is now empty: DataGrid, the last component older than the contract, is on it too.
RadioGroupreports throughonValueChange.onChangestill works and still fires — pass either, or both — but it was the oneChangeHandlerin the library under a DOM event's name, on a component that is not an<input>. It is deprecated and will go in a future major.
Dropdown and Select report through onValueChange too
The library's oldest component now holds its selection in useControllableState like every other
one, and reports it the way every other one does:
<Dropdown<string> label="Fruit" value={fruit} onValueChange={(next, { reason }) => setFruit(next as string)}>
<Dropdown.Item value="apple">Apple</Dropdown.Item>
</Dropdown>onValueChange hands back the selection in whatever shape value takes — the array in
multiple mode, the one chosen option otherwise — and a reason you can switch on exhaustively:
'select', 'deselect', 'select-all' and 'clear', the last two being the Dropdown.SelectAll
and Dropdown.Unselect rows, which act on the whole list rather than on one option. onChange is
deprecated and still fires with its old (value, values) pair, where the first argument is the
option acted on; pass either, or both, and nothing needs changing today.
Moving the pair onto the hook is also where a quiet bug went: defaultValue={0} and value={''}
were read as "nothing selected", because the old code tested the value for truthiness rather than
for being there. A falsy value is a value somebody can pick.
RadioGroup and the chart primitives have style trees
Box.components() now reaches all six: radioGroup (with a horizontal variant) and its label,
sparkline and its line/area/bar paths, progressRing and gauge with their track and
arc, miniDonut and its segment, and chartContainer. What used to be a default written into
the component is a node you can replace, so one override restyles every drawing in an app:
Box.components({ sparkline: { styles: { stroke: 'emerald-500', strokeWidth: 2 } } });ChartContainer's node is the interesting one: it holds the --chart-1 … --chart-6 palette and
its dark twin, so re-skinning every chart on a page — including a Recharts one that names no colour
of its own — is one override rather than a prop on every container. A series prop and a vars of
your own still win over it, exactly as before.
One thing to look at when you upgrade: a RadioGroup's label now wears the same type as a
Combobox's — 14px in gray-700, gray-300 in the dark theme — where it used to inherit
whatever was around it. Box.components({ radioGroup: { children: { label: { styles: … } } } }) is
where to say otherwise.
DataGrid takes Box props, and says why each thing changed
The grid is a Box now, so the element wrapping the bars, the rows and the pager takes the same props
as anything else — <DataGrid b={1} borderRadius={2} shadow="medium" props={{ 'data-testid': 'orders' }}>
— with no wrapper <Box> around it and no style tree override for a border. A style of your own
sits on top of the column-width variables the grid writes there rather than replacing them.
Its eight callbacks report a reason now. Four kept their names, because a handler written
(value) => … is still assignable to one that hands it a second argument — so this is additive and
nothing needs changing today:
<DataGrid
data={rows}
def={def}
onGlobalFilterChange={(text, { reason }) => reason === 'clear' && resetSearch()}
onServerStateChange={(state, { reason }) => refetch(state, { debounce: reason === 'filter' })}
/>onGlobalFilterChange and onColumnFiltersChange say 'filter' or 'clear',
onExpandedRowKeysChange says 'expand' or 'collapse', and onServerStateChange — the one most
people wire an API to — says which of 'page', 'page-size', 'sort', 'filter' or 'clear'
moved. That last one is worth having: the grid sends the pager back to the first page whenever a
query changes underneath it, and until now nothing said whether a page-1 snapshot was a navigation
or a reset.
The other four could not grow a details argument where their second argument already meant something else, so each has a new name beside it and the old one is deprecated and still fires:
onSelectionChange(event)→onSelectedRowKeysChange(keys, { reason })— the selection itself, and the event'sactionas a named reason:'select','deselect','select-all'or'clear', which are the fourDropdownalready reports a selection under. The old event'saffectedRowKeysis what it is still for.onSortChange(columnKey, direction)→onSortingChange(sort, { reason }), wheresortis{ columnKey, direction }orundefinedonce the third press clears it.onPageChange(page, pageSize)andonPageSizeChange(size)→onPaginationChange({ page, pageSize }, { reason })— one callback, because the two always moved together: a new page size always returns to page 1.
A column that adds itself up
aggregate on a column is the half of grouping the grid was missing: the column totals itself over
the rows under each group row, and over the whole grid when def.footer is on.
<DataGrid
data={people}
def={{
footer: true,
columns: [
{ key: 'country' },
{ key: 'username', header: 'People', aggregate: 'count' },
{ key: 'age', header: 'Avg age', aggregate: 'avg' },
{ key: 'salary', header: 'Payroll', aggregate: 'sum' },
],
}}
/>Five built-ins — sum, avg, min, max and count — or a function of your own, handed the
column's values and the rows they came from, so a weighted average can reach the column it weights
by. The four numeric ones read the numbers in the column and skip everything else, so a blank cell
is not a zero and a column holding no number at all answers null rather than 0: a total of
nothing is not zero, and a footer reading 0 over an empty grid is a claim about the data. count
counts rows rather than values, which is why it is the one that never skips anything. avg rounds
to two decimals, because an exact mean is longer than the cell it goes in — any other precision is
an AggregateCell, which is to an aggregate what Cell is to a row:
{
key: 'salary',
aggregate: 'sum',
AggregateCell: ({ cell }) => <Box px={3}>{cell.value === null ? '—' : format(cell.value)}</Box>,
}Every total covers the rows the filters left, so filtering the grid changes them.
Three things worth knowing. A group row's label stops spanning at the first aggregated column.
It used to run across every data column beside it, and a number under the wrong heading is worse
than a label with less room — so from there on each column draws its own value, and a group row's
figures line up under the same headings the rows below them do. With nothing aggregating, the span
is exactly what it was. The footer covers the rows the grid holds, which under server-side
pagination is the page rather than the table, because those are the only rows it has; a
server-computed total is what E3's row model will be for. And the footer is a row of the grid in
every sense — aria-rowcount counts it, it is numbered last, Ctrl+End lands in it and Up leaves
it, which is why it is pinned rather than parked at the end of the scroll.
Its style nodes are datagrid.footer, datagrid.footer.cell and datagrid.footer.label, with
datagrid.body.groupRow.aggregate for the same value on a group row. def.footer also takes
{ label }, which replaces the default Total in the first column that is not aggregating.
The DataGrid, restyled
The grid's default appearance was a spreadsheet: a gray-200 rule under every cell and down every
column boundary, 13px headings as dark as the data under them, and a large drop shadow around the
whole thing. It reads as one surface now — the chrome quiet, the data loud.
What changed, and why each one:
- Numbers line up. Every body cell is
font-variant-numeric: tabular-nums, so a column of figures shares a digit width instead of drifting. It is the one thing a data grid cannot do without, and the registry has no prop for it —cssis where a property with no prop goes, and it still compiles to one shared class. - A selected row finally looks selected. It tints (
indigo-50,indigo-950in the dark), pinned columns included.isRowSelectedhad been declared ondatagrid.body.cellanddatagrid.header.cellsince the grid shipped and was applied by nothing, so a selected row was legible only by its own checkbox. Both dead variants are gone; the appearance comes fromaria-selectedon the row, through the library's owngroupprop. - A group row reads as a section header, tinted the same way off its
aria-expanded. - Pinned columns float rather than fence. The hard border on the frozen edge is a soft directional shadow, so the pinned columns sit over the scrolled ones.
- Quieter chrome. Row separators drop to
gray-100, the column resizer from a 2pxgray-400rule to a 1px hairline, the header to 12px trackedgray-500, and the bars to the grid's own surface with a hairline under them — three stacked greys was what made it read as a spreadsheet. The container keeps a tightxsshadow instead oflarge. - One type scale. The grid sets
fontSize: 14once at its root and every part inherits it.
Three colours were measured rather than chosen: a muted row number at gray-400 is 2.60:1 on white,
and the header's menu button at gray-400 is 2.49:1 on gray-50 — both below what text and controls
owe. They are gray-500 (4.84:1 and 4.63:1), which is as quiet as the contrast allows.
If you override datagrid styles, the parts are unchanged — only their values. The two removed
variants are the exception, and neither ever painted anything.
A row-detail panel is a drawer
rowDetail shipped with no appearance of its own beyond a grey band. The row that opened the panel
looked like every other row, and the hairline between the two said the panel was the next row rather
than part of this one. They are one block now: the expanded row takes the panel's surface (gray-50,
and gray-950 in the dark — darker than the grid, because a drawer should read as a well) and gives
up its bottom hairline, and a 2px indigo-500 accent runs down the inline start of the row and on down
the panel, which is the only thing tying a panel this tall back to the row it belongs to. The accent is
logical, so it mirrors with the reading order. The expand chevron is a control rather than data now —
gray-500 at rest, the accent when open. The row also gets a hover one step off its new surface, since
the grid's own row hover is the colour an expanded row is already painted: it had stopped answering.
A panel that opens below the fold now scrolls itself into view. It is block: 'nearest', so a panel
already on screen moves nothing, and a grid that scrolls internally absorbs the scroll rather than the
page. The row that opened the panel is part of what gets revealed: datagrid.body.detailRow carries a
scroll-margin-block-start of one row, so a panel taller than the viewport aligns the row's top
rather than its own instead of pushing it off the screen — measured in Chrome, where without the margin
the row lands 40px above the scrollport. The scroll happens where the panel mounts rather than where the
row was toggled, because an auto panel's real height exists nowhere else: virtualization carries a
200px estimate for it. rowDetail: { scrollIntoView: false } turns it off.
Neutral rather than tinted on purpose: a selected row is the one that tints, so the two states stay
legible beside each other. Re-colouring the block is two keys — body.cell's isExpanded variant and
body.detailRow — which is all the docs site's own Orders demo does now, where it used to spell out a
3px frame across four nodes.
Excel and CSV, with nothing to install
def.export puts two buttons in the grid's top bar, and a ref on the grid is the same two calls for
a toolbar of your own:
<DataGrid data={people} def={{ topBar: true, export: true }} />const grid = useRef<DataGridHandle>(null);
<DataGrid ref={grid} data={people} def={{ columns }} />
<Button onClick={() => grid.current?.exportXlsx({ sheetName: 'Q1' })}>Download</Button>The file is what the grid is showing: the visible columns in their pinned order, the rows the filters and the sort left, the group rows and their totals. It adds exactly one thing back — a column hidden because the grid is grouped by it. Its values moved to the group rows rather than going away, and a spreadsheet with no Country column in it is not the grid that was exported.
The workbook is a real .xlsx, not a CSV wearing the extension: a bold header on a frozen row, column
widths taken from the grid's own, values that keep their type — a number is a number, a Date is a date
cell — an auto-filter, and the grouping as Excel's own outline levels, so a group left collapsed in
the grid opens collapsed in Excel with every row still in the file. An export runs no React, so a column
drawn by a Cell renderer says what it writes, and a column can name the number format its cells wear:
{ key: 'salary', header: 'Payroll', aggregate: 'sum', exportFormat: '$#,##0' },
{ key: 'name', exportValue: (row) => `${row.first} ${row.last}` },There is no ExcelJS to install. An .xlsx is a ZIP of XML parts, and this writes both — 3.80 KB gz
for the two formats together, in a chunk behind a dynamic import, so a grid nobody exports from carries
none of it and the grid's own entry grows 1.03 KB for the model and the buttons. A real .xlsx is
usually either a paid tier or a second dependency; here it is neither.
Two smaller things, both deliberate. A CSV starts with a byte-order mark, without which Excel reads
UTF-8 as the local code page and mangles every accented name. And a CSV value beginning =, +, -
or @ is quoted into text: a file a page generated must not run a formula somebody typed into a cell.
exportCsv({ escapeFormulas: false }) writes it as it stands.
Both take fileName, columns, groups and footer; exportXlsx also takes sheetName, headerFill
and headerColor as RRGGBB — a workbook carries one appearance, not a light and a dark one. The
buttons are datagrid.topBar.export and datagrid.topBar.export.button.
A grid that fetches its own rows
def.dataSource turns the grid the other way round. Instead of handing it an array and wiring five
callbacks to keep that array in step, you hand it one function and it asks:
<DataGrid
def={{
title: 'One million people',
globalFilter: true,
visibleRowsCount: 12,
columns,
dataSource: {
async getRows({ startRow, endRow, page, pageSize, sort, globalFilter, columnFilters, signal }) {
const res = await fetch(`/api/people?offset=${startRow}&limit=${endRow - startRow}`, { signal });
const body = await res.json();
return { rows: body.items, totalCount: body.total };
},
},
}}
/>There is no data prop in that call, and no loading, page or totalCount either — the grid owns all
four. Sorting, filtering and paging stop being work the browser does over an array and become part of a
request, which is what lets a grid stand in front of a table nobody could send to a browser at all. The
range arrives twice, as startRow/endRow for an API that offsets and as page/pageSize for one that
pages, because they always describe the same block.
A block is the unit of everything: one request, one wait, one failure. blockSize is how many rows
each asks for — the page size when def.pagination is beside it, 100 otherwise — and maxBlocks is how
many are kept before the ones furthest from the viewport are dropped and asked for again on the way back.
Rows that have not arrived are drawn as skeletons and are still counted, so the scrollbar and
aria-rowcount describe the whole result set from the first block rather than growing under whoever is
reading it; a placeholder row says aria-busy, and the row number, which is known without the row, is
the one cell it still fills.
A superseded request costs the network nothing: every call carries an AbortSignal that fires when the
sort or the filters change underneath it. And a reply that arrives anyway is dropped rather than
written over the newer one — the out-of-order race is the whole of what an async row model has to get
right, and it is not something a page should have to write again.
def.pagination beside a datasource is the pager rather than the scroller, and the two share one cache:
a page already fetched is shown with no round trip. It needs no totalCount any more, since the response
carries one — and a response may leave it out altogether, in which case the grid follows the rows and
treats a block shorter than it asked for as the end of the data.
A failed block shows one strip with the error's own message and a Retry that asks for every block that
failed. The grid invalidates its own cache whenever it changes the query; for the half it cannot see — a
filter of the page's own, a row somebody saved — refresh() on the grid's ref throws the blocks away and
asks again.
Two things a server-backed grid must not pretend: a select-all reaches the rows that are loaded, not the
table behind them, and an export writes what the grid holds. Grouping used to be the third — it is the
next section now. The existing onServerStateChange grid is untouched and still works — dataSource is
the answer for a new one.
The parts are datagrid.body.cell.placeholder and its bar, and datagrid.error with message and
retry. +2.51 KB gz on the DataGrid entry, and 0.15 KB on every other engine-carrying entry for the
two style-tree nodes — /a11y and /anchor, which carry no engine, moved nothing at all.
A group the server counts, and children it fetches when you open one
Grouping was the one thing a datasource did not offer, for a good reason: grouping the blocks that happen to be in the browser is not grouping the data. So the server does it. Say that it can, and Group By comes back in the column menu:
dataSource: {
// Without this, Group By is not offered at all: a `getRows` that ignored `groupKeys` would
// answer a group level with leaf rows, and the grid has no way to tell that it had.
grouping: true,
async getRows({ startRow, endRow, groupBy, groupKeys, signal }) {
const res = await fetch(`/api/people?` + new URLSearchParams({
offset: String(startRow),
limit: String(endRow - startRow),
groupBy: groupBy.join(','), // ['country'] — what to GROUP BY
groupKeys: groupKeys.join(','), // [] for the groups, ['Japan'] for what is inside one
}), { signal });
const body = await res.json();
return { rows: body.items, totalCount: body.total, groupCounts: body.counts };
},
}Every request now carries groupBy, the columns being grouped by, outermost first, and
groupKeys, the group it is inside. Shorter than groupBy and the rows wanted are group rows, one
per distinct value of groupBy[groupKeys.length], with groupCounts beside them; the same length and
they are that group's own rows. One rule covers both directions, and there is no second callback.
A group row is a row of the same shape: the level's own column, and whatever it totals. A column with
an aggregate reads its value off that row rather than adding up rows the browser has not got — so the
number on a group row is the server's arithmetic over the whole group, not over the fifty rows in hand.
Its count goes in the label; omit groupCounts and the label is the value alone.
Nothing under a group is fetched until its chevron is pressed. Each open group is a cache of its own — its own blocks, its own count, its own failure — and shutting one disposes of it and everything beneath. Eviction keeps the chain above a level in play, so scrolling deep inside a group never drops the group row it hangs off; that was measured, because the obvious least-recently-used order takes it first.
Two things the grid still declines to pretend. A group whose rows have never been fetched shows no
select-all checkbox: it cannot select what the browser has never seen, and a checkbox that did nothing
would be worse than none. And the totals come down with the group row, so a column with no aggregate
declared still shows nothing — declaring one is how you say a column totals, whoever does the arithmetic.
Measured in Chrome 152 on the built site, scrolling twelve blocks deep inside an open group: 109 ms worst frame gap against 105 ms for the same scroll through the ungrouped million-row grid beside it, and 52 ms back over blocks already cached. The walk over the open levels costs nothing the flat path did not. +1.45 KB gz on the DataGrid entry; every other entry moved by zero.
One fix came out of it. A group row carried aria-expanded, which is only defined on a row inside a
treegrid and is a serious axe violation inside a grid — it had been there since grouping shipped, and
no fixture had ever rendered a group row for the sweep to see. The open state is on the expand button,
where it always also was.
A grid whose rows hold rows
A file system, an org chart, a bill of materials: rows with rows inside them. def.treeData reads that
shape off the data and the grid does the rest — one column grows a chevron and an indent, and the whole
thing reports itself as a treegrid:
<DataGrid
data={files}
def={{
rowKey: 'id',
treeData: {
// Nested data: the field the children live in. `getChildren` works them out instead.
childrenKey: 'children',
// Which column carries the chevrons. Default: the first column of your own.
column: 'name',
// `true` for every row, a number for a depth, or a list of keys.
defaultExpanded: 1,
// Ticking a folder ticks everything in it.
selection: 'cascade',
},
columns: [
{ key: 'name', header: 'Name' },
{ key: 'size', header: 'Size', align: 'end' },
],
}}
/>The tree comes in two shapes, and both are the data you already have. Nested, where a row carries its
children (childrenKey, or getChildren to compute them); or flat, where every row carries the path to
itself (pathKey/getPath) — which is what an export, a WHERE path LIKE or a materialised-path table
hands you. A flat list needs no ordering: shallower rows are read first, and a row whose parent is missing
hangs off the nearest ancestor that is there.
A filter keeps the path to a match. Filtering a tree row by row would hide every match three folders down, so a row survives when it matches or something under it does — what is left is the matches and the way to them. A row that matches on its own keeps none of the children that did not, and a sort happens inside each parent, because a tree sorted across its levels is not a tree any more.
A shut row costs nothing. Nothing under one is built at all — no models, no cells — so ten thousand rows nobody has opened cost the page its top level; opening one builds that level and no more. The tree itself is built once and left alone, so an expand rebuilds none of it.
It is a treegrid, which is the only place ARIA allows a row to say it is expandable: every row
carries aria-level, aria-posinset and aria-setsize, and a row with something under it carries
aria-expanded. On the tree's own column, Right opens a shut row and Left shuts an open one —
and on a row that is already shut, Left steps out to its parent, which is APG's rule. Both follow the
reading order, so they swap in a right-to-left grid; on every other column they are the ordinary move
along the row.
Two things it does not pretend. Selection cascades only when you ask (selection: 'cascade', which also
makes a half-ticked row read as indeterminate) — a checkbox is otherwise the row it is on, like every
other row in the grid. And Group By is not offered on a tree at all: the rows are already in a shape.
Measured in Chrome on the built site, on the docs page's own ten-thousand-node tree (12 areas × 25
modules × 33 files = 10,212 rows): scrolling it is a 18 ms worst frame gap against 42 ms for the
thousand-row flat grid further up the same page, opening a folder is 16 ms — one frame's work — and
filtering the whole tree costs 82 ms on the first keystroke and 11–25 ms after, against 112 ms and
12–30 ms for that flat grid's own filter over a tenth as many rows. Right-to-left was measured with a real
dir, which no test environment resolves: ArrowLeft opens. +2.0 KB gz on the DataGrid entry and ~35 B
on every entry that carries the engine (the three new style-tree nodes); /a11y, /anchor and the export
chunk moved by zero, which is what attributes the rest.
A tree the server holds, one level per chevron
The tree above is in the browser. Put def.treeData beside def.dataSource and it is not: the grid asks
for one level at a time, and a folder nobody has opened costs the page nothing however deep it goes.
const dataSource = useMemo(
() => ({
async getRows({ startRow, endRow, treeKeys, signal }) {
// [] is the top of the tree, ['app'] what is in that folder,
// ['app', 'app/m3'] what is in the one under that.
const res = await fetch(`/api/files?path=${treeKeys.join('/')}&offset=${startRow}&limit=${endRow - startRow}`, { signal });
const body = await res.json();
return { rows: body.items, totalCount: body.total };
},
}),
[],
);
<DataGrid
def={{
dataSource,
rowKey: 'id',
// The tree is the server's. `hasChildren` is the one thing a row's own
// values cannot say, since what is under it has never been in the browser.
treeData: { hasChildren: 'folder', column: 'name' },
columns: [
{ key: 'name', header: 'Name' },
{ key: 'size', header: 'Size (KB)', align: 'end' },
],
}}
/>;The path is keys, so the server names a node the way it named it. treeKeys is built from
def.rowKey rather than from the values on screen — the ids the server sent come back to it verbatim,
outermost first, [] for the top. A row three deep asks with all three, so a query can be written against
the whole path or against the last segment alone.
A chevron is the server's answer, not a guess. hasChildren names a field (hasChildren: 'folder')
or works the answer out (hasChildren: (row) => row.kind !== 'file'). Without one no row grows a chevron:
a grid that offered one on every row would promise a level that may not exist, and a grid that fetched to
find out would defeat the whole thing.
It is the same mechanism as server-side grouping, not a second one beside it. A level per open path,
each with its own blocks, its own count and its own failure; a level is created when its row is opened and
disposed of the moment nothing reaches it; and eviction keeps the chain above a level in play, so
scrolling deep inside a folder never drops the row it hangs off. treeKeys and groupKeys are the same
field of the cache seen from two ends, which is also why the two are mutually exclusive — Group By is
not offered on a tree, whoever holds it.
Everything the eager tree gives the keyboard and the screen reader is unchanged: a treegrid, aria-level
/aria-posinset/aria-setsize on every row, aria-expanded on the ones that hold rows, and Right to
open with Left to shut and step out to the parent, following the reading order. Two differences, both
of them the honest answer rather than a gap. selection: 'cascade' is not offered — a tick cannot take
rows nobody has fetched — and defaultExpanded costs one request per row it opens, so a list of keys is
what it is for and true would fetch the whole tree. onExpandedTreeKeysChange still reports the rows
that are open and reachable: a row under one that has been shut is not open, and its level is gone.
The docs page's demo is a repository of 144,732 rows (12 areas × 60 modules × 200 files) generated a level
at a time and never built — beside the eager tree further up the same page, which holds all 10,212 of its
own, and that neighbour is the control. Measured in Chrome on the built site: scrolling three levels deep
with blocks arriving is a 46 ms worst frame gap against the eager tree's 56 ms on the same page and
the same harness, 29 ms back over cached blocks, and opening a folder is 1 ms — the level it adds is
a walk, not a rebuild. Thirty-three rows in the DOM either way. Right-to-left was measured with a real
dir, which no test environment resolves: ArrowLeft opens and ArrowRight shuts. +0.56 KB gz on the
DataGrid entry; /a11y, /anchor, the export chunk, the core engine and every other component moved by
exactly zero, which is what attributes the rest.
Grouping can be declared now, not only clicked
Row grouping was reachable only through a column's menu, which meant a grid could not start grouped —
no server render, no fixture, no demo that shows anything before the reader opens a menu. def.groupBy
is where the grid starts, and def.groupDefaultExpanded says which of those groups start open:
def={{
groupBy: ['country'], // outermost first; a grouped column is hidden, as the menu hides one
groupDefaultExpanded: 1, // `true` for all of them, a number for how many levels down
columns: [
{ key: 'name', header: 'Name' },
{ key: 'country', header: 'Country' },
{ key: 'salary', header: 'Salary', aggregate: 'avg', align: 'end' },
],
}}The menu takes it from there, and a group somebody opens or shuts stays where they left it. It also closed a hole in the accessibility sweep: a group row had never been rendered by a fixture, because no fixture could produce one.
A cell you can type into, and one place that judges it
A grid that only shows rows is a report; the thing that makes it an application surface is being able to
change a value in it. column.editable is the whole opt-in, and def.onCellEdit is the one function
that both judges an edit and is told about it — answer nothing to accept the value, or a string to refuse
it:
<DataGrid
data={people}
def={{
rowKey: 'id',
onCellEdit: ({ value, columnKey }) => (columnKey === 'name' && !String(value).trim() ? 'A name is required' : undefined),
columns: [
{ key: 'name', header: 'Name', editable: true },
{ key: 'salary', header: 'Salary', editable: true, align: 'end' },
{ key: 'active', header: 'Active', editable: true },
{ key: 'country', header: 'Country', editable: true, editor: { type: 'select', options: [{ value: 'Japan' }, { value: 'Peru' }] } },
],
}}
/>Which editor a cell opens is read off the value in it — a number gets a numeric field, a boolean a
checkbox, everything else a text field — so a column of numbers has a numeric keypad on a phone without
being told. editor names one of the four (text, number, checkbox, select) or configures it, and
EditCell is a control of your own, bound to cell.draft with cell.commitEdit() and cell.cancelEdit()
beside it — the same three calls the built-in four make, so a date picker is a component and not a second
copy of the commit rules.
A select editor opens its list with the editor, highlighting the value already in the cell, so
choosing one is the single gesture it looks like. The list is the whole control there — an editor that
opened and then waited for a second press to show what could be chosen is the interaction a select
editor
cannot design out of a native <select>, and it is the same on every way in: a double press, Enter, F2,
a printable character, or Tab arriving from the cell before. Escape closes the list and a second Escape
leaves the editor, which is the two-stage dismissal the pattern already has. Dropdown takes the
behaviour as a prop of its own — defaultOpen — for anything else built the same way.
A double press opens the editor, and a single one only chooses. A press makes a cell the current
one — where the arrow keys carry on from — and a double press on an editable cell opens it, the way a
spreadsheet reads one; a cell that cannot be edited takes both as focus and nothing else. A widget inside
a cell keeps its own presses, so a double press on a tree chevron, or on 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, Escape throws the draft away, Enter commits and
hands the keyboard back to the cell, 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. A printable character opens the
editor on that character, replacing the value the way a spreadsheet does, and a press somewhere else
commits. 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 — and aria-invalid plus aria-describedby on the field itself.
def.onCellEdit may be async, so a uniqueness check against a server is the same function: the
editor waits, and an answer to an edit the user has since abandoned is dropped rather than applied to
whatever they are editing now. A rejected promise is its message.
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 grid
over a def.dataSource be edited at all, since a value that was only reported would be gone on the next
block. onCellEditsChange reports the whole list, oldest first, which is 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. Two consequences
worth knowing: an edit does not re-sort or re-filter the grid — a row jumping out from under the
pointer as it is typed into is not an edit anybody asked for — and refresh() on a datasource drops the
edits with the blocks, because what the server says next is the newer answer.
The grid is one tab stop, the way APG says
A grid is a composite widget, and APG is explicit about what that costs the tab order: "Only one of the focusable elements contained by the grid is included in the page tab sequence." Until now this one had seventy-eight — measured on the docs site's own grid: one row-selection checkbox per rendered row, one resizer and one menu button per column, one input per filter. Tab from a cell walked into them one at a time, so crossing a grid took as many presses as it had rows, and the first of them landed on a checkbox in a near-empty column with no cell ring drawn anywhere.
Now Tab leaves. Everything the grid draws inside a cell carries tabindex="-1", and the way in is the
one APG names: Enter or F2 steps into the cell's controls, Tab walks between them and wraps
inside that cell, and Escape hands the keyboard back. That is APG's reading of a grid
(tabNavigation="none"), and the editor already worked this way — Tab in an open editor commits and
opens the next editable cell, which is APG's "when grid navigation is disabled" half.
def.tabNavigation="cells" is the other reading, for the screens that want it: Tab walks to the
next cell and on into the rows after it, Shift+Tab walks back, the way a spreadsheet reads a grid. It is
opt-in because it costs the keyboard its way out — at either end of the grid the key is let through, so
Tab still escapes rather than being swallowed for ever.
Space selects the row the focused cell is in, and deselects it again; from the header cell the select-all box sits in, it toggles every row. That is the spreadsheet reading, and it is the half that makes the rest safe: with the checkboxes out of the tab order, Space is how a grid is selected without a mouse. A grid with no row selection has nothing to toggle, so Space there keeps doing what Enter does.
Breaking changes
- Nothing inside a DataGrid cell is a tab stop any more. The row-selection checkboxes, the select-all box, the column resizers, the column menu buttons and the filter inputs all carry
tabindex="-1", so the grid is the single tab stop APG specifies. A page that tabbed into a column's filter box now presses Enter or F2 on that cell instead — ordef.tabNavigation="cells", if what it wanted was for Tab to walk the grid at all. A test that tabbed to a grid widget should focus it directly, or drive Enter/F2 from its cell. - Space on a grid cell selects the row rather than acting like Enter. It was an undocumented synonym for Enter (opening an editor, stepping into a widget); it is the selection key now, on every grid that has
def.rowSelection. Enter and F2 are unchanged and are still the only ways into a cell. Where a grid has no row selection, Space still does what Enter does. Overlayplaces a layer instead of translating one, so its four positioning props are gone.anchorSideisside(anchorSide="bottom"is the defaultside="bottom"; the old'top'overlapped the anchor, whichside="bottom" offset={0}does not — use a negative margin if you need the overlap).adjustTranslateX/adjustTranslateYareoffseton the ÷4 scale for the gap andalignfor the sideways nudge (adjustTranslateY="4px"isoffset={1}).onPositionChangeisonSideChange, which reports the side rather than page coordinates — nothing measures a position any more, so there are none to report.Tooltip'sadjustTranslateX/adjustTranslateYareside,alignandoffset. The default gap is unchanged (offset={1}, 4px), so a tooltip that took no nudge needs no change.fliponOverlayandTooltipis the placement prop, not the CSS one. It shadows the Box prop that writesscale, the wayTooltipalready shadowscontentandopen— a mirrored layer is a rarity, and one placement vocabulary is worth more. A child of the layer still takes the CSSflip.- The
dropdown.itemsupvariant is gone;closedUpstays. It only ever carried the entrance's starting style, and the entrance cannot know which way the popup went (see above). ABox.components()override that styledupshould move toclosedUpor to the basestartingStyle. - A layer must not be declared inside its trigger.
Overlaystays in the DOM where it is written now, so<Button>{overlay}</Button>puts arole="listbox"or arole="menu"full of buttons inside a<button>— content no keyboard can reach. It used to work by accident, because the portal moved the layer before a browser saw it. Render the layer beside the trigger and pass the trigger asanchor. The library's ownDropdownpopup and DataGrid column menu were both doing this and have moved. - Nothing is portalled into
#box-kite-portalany more, except on a browser with no Popover API. CSS or a test that reaches a popup through that container —#box-kite-portal .item, or a query scoped to it — should target the popup itself instead; it is now a sibling of its trigger. The container is no longer created at all on the top-layer path, andBox.Themeneeds nothing copied into it, since the layer inherits the theme it was declared in.
Fixes
A clicked grid cell showed nothing at all. The current cell was drawn with
:focus-visible, which is specified not to match pointer input, so the ring appeared only once an arrow key had been pressed — and with an editor now opening on a double press, the cell a press had just chosen was the one thing on screen that did not say so. A body cell draws its ring for the cell that holds focus instead —:focus-within:not(:has(:focus)), so a press on a widget the cell holds (a selection checkbox, a tree chevron) leaves the ring where it was rather than drawing a second one around that widget's cell. A header cell keeps:focus-visible, because its click sorts and the consequence is already visible. The ring still goes when the grid loses focus: a current cell that survives blur is what a range selection extends from, and it arrives with that.Every option of a grid's
selecteditor came up painted as selected text. A double press selects the word under it before any handler runs, and the editor then replaced the text that selection covered — so Chrome repaired the stranded range to the nearest boundary it could find, and the range reappeared over whatever rendered there next: the whole list, the moment it opened. The press that opens an editor drops the selection it made, since the content it covered is on its way out; and an option is a choice rather than prose, sodropdown.itemdeclaresuser-select: nonethe way the trigger has since it shipped — which also stops a drag across the list painting one.A printable character typed on a
selectorcheckboxcell became the cell's value. One character opens the editor on that character, which is what a spreadsheet does with a field — but a list is chosen from rather than typed into, so the draft was seeded with a letter no option could match: the editor came up showing nothing at all, and an Enter after it committed the letter itself into the column. Acheckboxread the same character astrue. Only the two editors that are typed into take the keystroke now, and anumbertakes it as a number — seeded with the string, an Enter straight after the key committed'7'where the column held7. A key that is not part of a number opens the field on the value it found.A deprecated prop would have told forty-five coding agents to stop importing the whole component. The scan behind the skill's "no longer the spelling to write" list took the first
@deprecatedin a component's source and named the module after it — fine while every deprecation insrc/components/was a whole component, and wrong the moment one was a prop:RadioGroup's renamedonChangecame out as@box-kite/react/components/radioGroup, which an agent reads as "do not import this". A tag above a prop signature now names the prop (…/radioGroup#onChange) and only a tag elsewhere names the module, and every deprecation in a file is listed rather than just the first.The docs site's own keyboard access, in the two places it was a clickable
<div>. The nine category switchers on /box — the largest prop reference on the site — could only be reached with a mouse, so eight of its nine panels were unreachable and a screen reader was told nothing about them. They are arole="tablist"of real buttons over the library's ownuseRovingFocusnow, with arrow keys, Home/End and arole="tabpanel"that says which tab named it; the forty "Show code" toggles below them are buttons witharia-expanded. One caption on the same page also failed contrast at 3.74:1 and does not now.Every table on the docs site rendered as a stack of full-width blocks.
display: blockon a<Box tag="table">costs the table its layout and its semantics, and five pages had written their own copy of the same broken table. There is one shared table now, carrying the display values each element needs — and the props above are what let it stop reaching for the escape hatch to collapse its borders.Inline code in the prose of ten documentation pages was rendered as a block. Each
<code>took a line of its own, breaking the paragraph around it into stripes — the local helper was missingdisplay="inline", which is the trap the library's own rules warn about: aBoxisdisplay: blockwhatever element it renders. Nothing in the library changed; the pages read as paragraphs again. The same pages' tables also lost their last nine inlinestyleattributes tocss={{ borderCollapse: 'collapse' }}, which is what the escape hatch is for.A
Dropdowncould not be given a value of0or an empty string. BothvalueanddefaultValuewere tested for truthiness on the way in, so<Dropdown<number> defaultValue={0}>started with nothing selected andvalue={0}selected nothing however many times it was set — whilevalue={1}worked, which is what made it read as a puzzle rather than a bug. The test is againstundefinedandnullnow, and a falsy value is a value somebody can pick.Three nodes of the DataGrid style tree could not be styled at all.
cleantells the engine to use no component styles, so passing it beside acomponentsilently voided the very node thatcomponentnamed — and the expand chevron (body.cell.rowDetail), the group-row expander (body.groupRow.expandButton) and the pager buttons (bottomBar.pagination.button) all passed both. Anything written under those three keys, by the library or by aBox.components()override, was dropped on the floor. The three call sites name a component and no longer also claim to be clean;cleanstill strips aButtonthat names no component, which is the use it was meant for.Every scrolling
DataGridshowed a horizontal scrollbar it did not need, off by exactly the scrollbar's own width. The flexible columns were distributed across the width of the grid container, but they are laid out inside the scroller — and the vertical scrollbar sits between the two, so the columns came out 15px wider than the space they had andscrollWidth − clientWidthmeasured exactly 15 at every viewport size. The width is measured on the scroller now. Two things follow it. The scroller isoverflow-x: autorather thanscroll, so a grid whose columns genuinely fit no longer reserves a track it never uses; one whose columns do not fit still scrolls, and the virtualized body keeps its vertical scroll from the same rule as before — naming one overflow axis computes the othervisiblecompanion up toauto. And the scroller reserves its vertical scrollbar's space up front (scrollbar-gutter: stable), because the columns are now sized to a width the scrollbar can change: without it, opening a row-detail panel on a grid that was not yet scrolling took 15px away and the columns reflowed a frame later, flashing a horizontal scrollbar on the way. A grid that can never scroll vertically —visibleRowsCount: 'all'— reserves nothing, since it would only lose the 15px.Six of the DataGrid pager's controls had no accessible name at all. The four navigation buttons are chevrons and the chevrons are decorative, so a screen reader reached four buttons called nothing; the rows-per-page
<select>and the page-number input were unlabelled beside them, both critical axe failures. All six are named now — and the a11y sweep grew a paginated grid fixture, which is what found them: the two DataGrid fixtures it already had never rendered a pager.The DataGrid's two tree props were spread onto the element it renders.
expandedTreeKeysandonExpandedTreeKeysChangewere never taken off the props on their way through, so they reached<Box>with everything else — exactly what the comment above that line forbids ("a new grid prop cannot leak onto the element by being forgotten"). Nothing was visible, since Box forwards only what is inprops, but a future prop of that name on Box would have been set by a grid that never meant to. Both come off now.A
DataGridgiven a callback but no value prop did nothing at all.<DataGrid onPageChange={track}>without apagebeside it fired the callback on every press of the pager and never moved — and the same foronGlobalFilterChangewithoutglobalFilterValue,onColumnFiltersChange,onPageSizeChangeandonExpandedRowKeysChange. The grid read the handler as "the caller owns this state", so a grid wired up only to watch its user was left with a pager, a filter box and a set of expanders that could not change anything. A handler is a listener; ownership is the value prop, which still wins wherever it is passed.