Box Kite 2.2.8
28 October 2026 · npm · Compare v2.2.7...v2.2.8
Every interaction on the page in one typed stream, Box.onInteraction, and @box-kite/react/analytics to send it somewhere with a session on every record, while an app that never listens pays for one comparison.
Highlights
- Every interaction reaches one handler — a dialog opened, a grid sorted, a cell edited, a press: one typed event each, with what people typed kept on the page unless a listener asks for it.
- @box-kite/react/analytics — batched JSON records with a session, consent as a state and a beacon transport, in 1.9 KB gzipped.
Every interaction on the page now reaches one handler
Box.onInteraction(listener) hears everything a person does with the library in one stream: a dialog opening and why, a tab chosen, a menu command, a slider let go, a DataGrid sorted, filtered, paged, edited, pasted into, grouped or exported, a toast shown and dismissed, and a press on any element a component styled. Each event is { kind, component, type, reason, value, details, source, event, target, timeStamp, time, url }:
kindsays what reported it.'change'is a component's own change, andtypeis the callback it mirrors without itson('openChange','sortingChange','cellEdit').'dom'is aclickorchangeon anything a component styled.'custom'is the app's own.componentnames it:'dialog','datagrid','menu.item', or for a press the nearestdata-bk.reasonis the word the callback is given.sourceis'app'when code did it (a call through a ref, a timer, atoast()) and'user'otherwise, so a dashboard counts what people did.valueanddetailsare plain JSON copies, safe to send as they stand: aDateis its ISO string, and a function, a React element or a DOM node is left out.- What a person typed stays on the page unless a listener asks for it. A search typed into a DataGrid, a cell's value before and after an edit, the cells a paste wrote, a date picked or typed, the rows a
Comboboxselected and a toast's text are all left out, andurlhas no query string and no hash, since a reset link or a token lives there.Box.onInteraction(listener, { content: true })hands all of it over, for a listener that keeps it on the page or has the consent to send it. A form reports the names of the fields it sent and a checkbox itscheckedstate either way. eventandtargetare the DOM event behind it and the control that handled it, also for a change the component was never handed an event for, like a DataGrid header sorted by a press.- The events are typed. Narrow on
kindand thencomponentandtype, andvalueis the type that change carries.
const stop = Box.onInteraction((event) => {
if (event.kind === 'change' && event.component === 'datagrid' && event.type === 'sortingChange') {
console.log(event.value?.columnKey, event.value?.direction);
}
});An app that never listens pays for one comparison. With nobody subscribed, a component's report returns at once: nothing is queued, timed or kept. Everything else (the batching, the JSON copies, the press listeners) is a chunk of under 1.5 KB fetched with the first subscription. So subscribe early, in the module that boots the app: nothing that happens before the first listener is kept for it.
Delivery is fire and forget: events are handed over in order, in a background task after the handler, so a slow listener never delays the interaction it reports, and a listener that throws is reported rather than breaking the control. pagehide delivers what is still queued, and Box.flushInteractions() delivers it now. Nothing reports per frame, per hover or per keystroke: a drag reports where it ends, so a slider is valueCommit and a dashboard layoutCommit. One choice is one event: a component built from others keeps its parts quiet, so a day picked in a DatePicker reports datePicker.valueChange and datePicker.openChange, not the Calendar inside it as well. A Menu.Item takes a value, which is what its select reports.
Presses need one line of configuration. Box.configure({ componentAttribute: true }) writes data-bk="<component key>" on every element a component styles, which is how a press is named. A press that changed something arrives as that change, which carries it as its event, so one click is one event; only a press nothing explained is a dom event. Set it in a module that runs on both the server and the browser, or hydration sees two different trees.
An app reports its own moments on the same stream with Box.reportInteraction({ component, type, reason, value, details, event, content }): a page view, a hover, a wizard step, with content naming which of 'value' and the details keys a listener has to ask for. Box does not wrap the handlers an app passes in props, because that would cost every render and report a hover per frame, so a moment worth counting is named where it happens:
<Button props={{ onMouseEnter: (event) => Box.reportInteraction({ component: 'pricing-cta', type: 'mouseEnter', event }) }}>Pricing</Button>@box-kite/react/analytics sends that stream somewhere, with a session on every record
createAnalytics({ send }) turns every interaction into a flat JSON record, batches it, and sends it as the page is hidden, which is the last moment a phone is sure to give a page. It is 1.9 KB gzipped with no React and no engine in it: it reaches nothing but the channel.
import { beacon, createAnalytics } from '@box-kite/react/analytics';
const analytics = createAnalytics({ send: beacon('https://collect.example.com/events') });- A record is
{ id, name, kind, component, type, reason, value, details, source, trigger, label, time, url, sessionId }.nameiscomponent.type(tabs.valueChange,router.navigate),triggerthe DOM event's type,labelthe words on the control that was pressed (a button's, a link's, a tab's, never a cell's or a field's),timeISO 8601, andidunique per record, so a collector that is sent a batch twice can keep it once. sessionIdgroups one visit. It is kept insessionStorage, so it survives a reload and ends with the tab, and a new one starts aftersessionTimeout(30 minutes) with nothing happening.visitor: trueadds avisitorIdthat groups one browser across visits, kept inlocalStorage. It is off by default, because a lasting identifier is the kind a reader in the EU has to agree to first.- Consent is a state.
consent: 'pending'holds what happens in memory, stores no id and sends nothing untilanalytics.consent('granted');'denied'drops what was held and forgets both ids. The default is'granted'. content: trueis the stream's own option: what people typed, toast text, and the query and hash.- Transports are functions.
beacon(url)posts{ "events": [...] }withsendBeacon(akeepalivefetch where the beacon is refused), astext/plain, which keeps it a request with no preflight.consoleLog()prints a record a line. A vendor is one more function:send: (events) => events.forEach((e) => posthog.capture(e.name, e)). batch: { size, wait }(20 records, 5 seconds),filter(event)to leave interactions out,analytics.flush()andanalytics.stop().
The docs site runs on it: with ?interactions in a URL (or in development) every record is printed to the console, and the site posts to the collector its build is given.
Breaking changes
None.
Fixes
The three buttons at the top of the home page were each a button inside a link. That is two controls in one place, focused and announced twice, and invalid HTML; each is now one link that looks like a button.
A day clicked in a
Calendarreported its focus moving with the reason'keyboard'.onFocusedDateChangenow says'select'for a day chosen by any device, so'keyboard'means an arrow key.A
Dialog, aPopoveror the DataGrid's bottom sheet could flash scrollbars as it opened. The entrance scales or slides the layer, and while a transform runs the layer is the containing block of everyposition: fixedelement inside it, so a password manager's badge on a text field was placed relative to the dialog rather than the viewport and itsoverflow: autoscrolled to it until the transform settled. The layer now keepsoverflow: hiddenuntil its entrance has finished and goes back to it as its exit starts, so nothing inside it can scroll while it moves. Content too tall for the layer still scrolls once it is open.