Shared UI components for web frontends — framework-free core, React and Preact renderers.
  • TypeScript 69.5%
  • CSS 25.8%
  • JavaScript 4.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-19 12:54:35 -04:00
dist Make Select width explicit 2026-09-19 12:53:17 -04:00
scripts Capture Bones parent containers 2026-09-12 23:54:31 -04:00
src Make Select width explicit 2026-09-19 12:53:17 -04:00
.gitignore Maintain repository tooling instructions 2026-09-12 23:54:31 -04:00
AGENTS.md Maintain repository tooling instructions 2026-09-12 23:54:31 -04:00
CHANGELOG.md Make Select width explicit 2026-09-19 12:53:17 -04:00
LICENSE Initial commit 2026-08-08 19:45:32 -04:00
package-lock.json v0.5.0 2026-09-19 12:54:35 -04:00
package.json v0.5.0 2026-09-19 12:54:35 -04:00
README.md Make Select width explicit 2026-09-19 12:53:17 -04:00
RELEASING.md Make Select width explicit 2026-09-19 12:53:17 -04:00
tsconfig.json Initial commit 2026-08-08 19:45:32 -04:00
tsup.config.ts Initial commit 2026-08-08 19:45:32 -04:00
tsup.preact.config.ts Initial commit 2026-08-08 19:45:32 -04:00
vitest.config.ts Initial commit 2026-08-08 19:45:32 -04:00

icu-ui-kit

Toasts, captured loading layouts, and shared controls for the fleet's web frontends.

Ported from implementations that were already in production rather than designed fresh: an id-keyed toast store with transaction lifecycle helpers, a three-state loading set that refuses to let "waiting", "empty" and "failed" look the same, an accessibility set — a modal dialog on the native <dialog> element, the live-region announcer the toast store has always expected, and the two helpers that decide whether a failed transaction is a genuine error or a user pressing Reject — and a control set: a copy field that survives the plain-HTTP deployments this fleet actually has, an anchored surface on the Popover API, a single-choice control, a tablist, an edge drawer, a disclosure, a page selector and a presentational table shell.

  • The platform first, by default. The modal is a native <dialog>, the anchored surface is popover="auto". Each of those replaced a hand-rolled version, and each replacement deleted a portal, a z-index or an ARIA pattern nobody could test. Select was native too, for years — see its own section for the one case where that bet was later reversed, and why.
  • Depends on nothing. Not React, not Preact, not wagmi, not viem, not Tailwind. react and preact are optional peers; the core has no peers at all.
  • Imperative call sites. The toast store is a module-level singleton, so txToastPending(hash) works from inside an async transaction flow. No provider, no hook, no context.
  • One transaction, one toast. A pending toast is replaced in place by its outcome. A transaction never stacks two notifications.
  • A real stylesheet, driven by CSS variables. Map your tokens onto ours; do not adopt a design system.
  • Per-component CSS. CSS cannot tree-shake, so the stylesheet is split behind subpath exports and you ship only the parts you render.

Why this is separate from the wallet-connection kit

That kit peer-depends on wagmi ^2, which makes it uninstallable in the two frontends already on wagmi 3. This kit peer-depends on nothing chain-shaped. A transaction hash is a string, a chain id is a number, and the block-explorer lookup is a function you inject. That constraint is the reason the package exists; do not relax it.

Install

// package.json
"dependencies": {
  "icu-ui-kit": "git+https://git.gui.icu/dev/icu-ui-kit.git#v0.4.0"
}

The pin is a tag, and tags here are immutable — a published one is never moved. Upgrading means changing the ref and re-resolving the lockfile; see RELEASING.md.

import "icu-ui-kit/styles.css"; // once, before your utility layer

…or take only the parts you render — see Stylesheet parts.

⚠ Bundlers: you must dedupe the framework

Add this to every consuming app's vite.config.ts, adjusting the list to the framework you actually use:

resolve: {
  dedupe: ["preact", "preact/hooks", "preact/jsx-runtime", "preact/compat"],
  // React apps: ["react", "react-dom"]
},

Why. preact and react are peer dependencies here, but they are also dev dependencies — the kit cannot build or test itself without them. A bundler resolves a bare import relative to the importer's real path, so preact/hooks inside the kit's tree finds the kit's copy while your own components find yours. Two framework instances, one page.

How it fails. Not gracefully, and not where you'd look:

TypeError: can't access property "__H", H is undefined   # preact
Invalid hook call. …more than one copy of React…         # react

__H is Preact's hook state, which the second instance never initialised. The throw lands at first render of the first kit component that uses hooks — so an app importing only Bones/Loading (no hooks) looks completely fine and then breaks the day it adopts Modal.

⚠ vite build, tsc --noEmit and eslint all pass on the broken bundle. All three were green on an app whose production build contained both copies; only loading the page caught it. Do not treat a green build as coverage here.

Verify it, per app — this is a real gate, not a formality. With build.sourcemap on, ask the bundle which framework files it actually pulled in:

cat dist/assets/*.map | tr ',' '\n' \
  | grep -oE '[^"]*node_modules/(preact|react|react-dom)/[a-z/]*dist/[a-z.]*\.js' \
  | sort -u

Every path must sit under your node_modules. A path containing icu-ui-kit/node_modules/ is the bug, and it is the only cheap way to see it.

Scope. This bites hardest during adoption, when the kit is linked with file: — a published tarball installs no devDependencies, so the second copy does not exist. Keep the dedupe anyway: it is free, and it makes a file: link and a tagged install behave identically.

Three entries — take only what you need

entry peers use when
icu-ui-kit/core none the store, the tx helpers, the explorer resolver. No UI, no framework.
icu-ui-kit/preact preact the components in a Preact app
icu-ui-kit react (>=18) the components in a React app

The two UI entries export the same names, components and core alike, so a host's call sites port between them unchanged. src/__tests__/entryParity.test.ts is the enforcement, not the intention.

Export surface

core

OUTCOME  announce  callHost  clearAnnouncements  clearToasts  compact
configureAddress  configureMotion  configureToasts  configureTxToasts
dismissToast
displayAddress  explorerBase  formatAddress  formatTimeAgo
getAddressChecksum  getAnnouncerState  getToasts  isChunkLoadError
isTxOutcome  isUserRejection  notify  pauseToasts  pushToast  reloadOnce
motionEnabled  motionAttrs  resumeToasts  setAddressChecksum  shortHash
subscribeAddress
subscribeAnnouncer  subscribeToasts  timeAgoTick  toEpochMs  toastError
toastInfo  toastSuccess  toastWarning  truncateAddress  txErrorMessage
txExplorerUrl  txToastPending  txToastReplaced  txToastResolved
types: AddressOptions  AnnouncePoliteness  AnnouncerState  CompactOptions
       MotionOptions
       TimeInput  Toast  ToastDurations  ToastInput  ToastLink  ToastOptions
       ToastPlacement  ToastStatus  TruncateOptions  TxOutcome
       TxToastOptions  TxToastReplacedOptions  TxToastReplacement
       TxToastTransaction

. and ./preact — everything above, plus

Address  Amount  Popover  ContextMenu  Announcer  Badge  Chip  Button  ButtonGroup  Card  CardBody
CardFooter  CardHeader  Code  Snippet  Table  Modal  ModalBody  ModalFooter
ModalHeader  Accordion  Divider  Drawer  EmptyState  ErrorBoundary  Field
IconButton  Input  Kbd  Loading  Alert  Pagination  Select  ComboBox  Bones
Spinner  StatTile  Switch  TabPanel  Tabs
Textarea  TimeAgo  Toasts  TokenIcon  Tooltip  VisuallyHidden  FloatingPanel
TabStrip  Aura  Chart  Progress  AmountField  Navbar  Slider  Checkbox
       RadioGroup
makeStatusChip  pageRange  useTableSort  useContextMenu  useFloatingPanel
boneContainerStyle  boneStyle  Bone  BoneContainer  BoneHorizontal  BoneProfile  BoneSheet  BoneTier
useDraggablePanel  useResizablePanel  progressPercent  progressValueText
sanitizeAmount  formatAmount  hasAmount  sliderPercent  sliderValueText
types: AddressProps  PopoverProps  ContextMenuProps  ContextMenuItem
       ContextMenuSeparator  ContextMenuEntry  ContextMenuTrigger
       AnnouncerProps  ChipProps
       ChipSize  ChipTone  ChipVariant  BadgeProps  BadgeSize  BadgeTone
       ButtonProps  ButtonSize  ButtonTone  ButtonVariant
       ButtonGroupProps  ButtonGroupOrientation  CardProps  SnippetProps  SnippetPlacement  SnippetVariant
       TableColumn  TableProps  SortDescriptor  ModalProps  ModalSize
       AccordionProps  AccordionVariant  DrawerProps  DrawerSide  EmptyStateProps
       ErrorBoundaryProps  FieldProps  IconButtonProps  InputProps  InputVariant
       LoadingProps  SpinnerSize  AlertProps  AlertTone  AlertVariant  PaginationProps  PaginationVariant  SelectOption
       SelectProps  SelectVariant  ComboBoxProps  StatTileProps  SwitchProps  SwitchSize  TabItem  TabPanelProps
       TableSort  TabsProps  TextareaProps  TimeAgoProps  ToastsProps
       TokenIconProps  TooltipProps  TooltipPlacement  FloatingPanelProps  Position  Size
       PointerDownLike  UseFloatingPanelOptions  UseFloatingPanelResult
       UseDraggablePanelOptions  UseDraggablePanelResult
       ProgressProps  ProgressSize  ProgressTone  AmountFieldProps  AmountProps
       UseResizablePanelOptions  UseResizablePanelResult  TabStripItem
       TabStripProps  AuraProps  AuraShape  ChartProps  ChartMark  ChartScale
       SliderProps  SliderSize  CheckboxProps  CheckboxSize
       RadioGroupProps  RadioOption  NavbarProps  NavbarSlot  FormatAmountOptions
       ChartSeries

This block is asserted against the built .d.ts by src/__tests__/readmeExports.test.ts — an export list that has drifted is documentation that lies, and 45 of these were missing before it existed.

Layout and value additions

Badge overlays a bounded count on any trigger; ButtonGroup joins related buttons without losing their group name; Navbar provides desktop navigation and a modal mobile drawer; Amount formats base units without a floating-point round trip. Navbar accepts render functions for navigation and actions so a router can close the mobile menu without the kit depending on that router.

Toasts

Mount the stack once, at the app root, and raise toasts from anywhere.

import { Toasts, toastError, toastSuccess, notify } from "icu-ui-kit"; // or /preact

function App() {
  return (
    <>
      <Routes />
      <Toasts />
    </>
  );
}

toastError("RPC refused the request");   // stays until dismissed
toastSuccess("Position closed");         // fades after 2500ms
const id = notify("Uploading…", { ms: 0 });
notify("Uploaded", { id, status: "success" }); // same slot, updated

Rules the store enforces, each of which is load-bearing:

  • Same id replaces, never appends. That is how a transaction's pending toast becomes its outcome toast instead of stacking a second one.
  • pending never auto-dismisses. The one status the kit pins, and it is mechanical: the store guarantees a pending toast is replaced in place by its outcome, so it already has a terminus. A timer would break that invariant and leave work in flight with nothing on screen.
  • Every other status, error included, takes the same default. ⚠ The kit holds no position on how long an error should stay readable — that depends on what else your app shows. If the failure is also recorded in a log, a drawer or a banner, the default is fine; if the toast is the only record, pin it: configureToasts({ durations: { error: 0 } }). Decide it per app rather than inheriting it.
  • Uniform lifetimes are what the bundled <Toasts> deck needs, because its cards are positioned by depth index and unequal lifetimes make the stack drain out of order. That is a constraint of that renderer, not of the store — if you render your own surface, ignore it.
  • An explicit per-call ms always wins, for any status. It is step 1 of the resolution order.
  • ms: 0 pins any toast until it is dismissed or replaced.
  • Updating a slot disarms the previous timer, so a refreshed toast gets the full new timeout rather than the remainder of the old one.

Screen readers

<Toasts/> is deliberately not an aria-live region. If it were, the toast appearing and whatever announcement raised it would both be read out. Wire your app's existing announcer instead:

import { configureToasts } from "icu-ui-kit/core";

configureToasts({
  onAnnounce: (message, politeness) => myAnnouncer.say(message, politeness),
});

politeness is "assertive" for errors and "polite" for everything else. The default is a no-op — silence, not a double read-out.

If you have no announcer, the kit now ships one. That hole was the kit's own making: onAnnounce expected a host announcer and nothing satisfied it, so four frontends hand-rolled the same provider and two have no aria-live anywhere and are simply silent.

import { Announcer, announce, configureToasts } from "icu-ui-kit"; // or /preact

configureToasts({ onAnnounce: announce });   // once, at startup

// once, at the app root, next to <Toasts/>
<Announcer />

announce(message, politeness?) is callable from anywhere — it is a module-level store like the toast store, not a context, so it works from an async transaction flow and not only from inside a component. It renders one polite and one assertive region, clearing and rewriting so that the same message twice in a row is still spoken. It does not re-derive politeness: the store already decided, and two places deciding is how they come to disagree.

Transaction lifecycle

import { configureTxToasts, txToastPending, txToastResolved, txToastReplaced } from "icu-ui-kit/core";

// Once, at startup. Sync or async; the result is cached per chain id.
configureTxToasts({
  resolveExplorerBase: async (chainId) => (await db.getChain(chainId))?.explorerUri ?? "",
});

await txToastPending({ hash, chainId });            // spinner, pinned
await txToastResolved(hash, receipt.status, chainId); // same slot, settled

resolveExplorerBase(chainId) => string | Promise<string> is the only hook into the outside world. It replaces the original's direct IndexedDB read: the cache stays in the kit (a toast per transaction, all wanting the same base URL), the lookup belongs to the host. Concurrent lookups for one chain collapse into a single call, a throw resolves to "", and calling configureTxToasts again drops the cache.

Return "" and the hash renders as plain monospace text with no link. That is a supported state, not a degraded one — it is what the original did for a chain with no explorerUri. When a base is known the hash renders as a bare <a target="_blank" rel="noreferrer noopener">, never a router link, because the explorer is another origin and a same-origin click handler (preact-iso, react-router) must not get the chance to swallow it.

txToastReplaced(oldHash, replacement) handles viem's onReplaced: the old hash's slot becomes the note explaining where it went, and a fresh pending toast opens on the replacement hash — which the later txToastResolved settles in place.

OUTCOME maps a status to its presentation, and is exported so a host can extend or inspect it:

status title toast
success Transaction confirmed success
reverted Transaction reverted error
failed Transaction failed error
missing Transaction not found error
cancelled Transaction cancelled warning
replaced Transaction replaced warning
repriced Transaction sped up info

Anything else becomes Transaction <status> as an info toast.

Which toast? — isUserRejection / txErrorMessage

The kit owned the notification but gave a caller no way to decide which one to raise, so every dapp re-derived "did the user reject in their wallet, or did this genuinely fail" — and the ones that got it wrong show a red error toast for a cancel.

import { isUserRejection, txErrorMessage, toastError, toastInfo } from "icu-ui-kit/core";

try {
  await writeContract(config, request);
} catch (e) {
  if (isUserRejection(e)) toastInfo("Transaction cancelled");
  else toastError(txErrorMessage(e, "Swap failed"));
}

Both live in core and are structural: they read properties off an unknown value and never instanceof-test a vendor error class, so there is no viem and no wagmi import and one implementation serves apps on wagmi 2 and wagmi 3 alike. isUserRejection walks the cause chain for EIP-1193 code 4001 / UserRejectedRequestError with a message fallback for providers that only stringify; txErrorMessage returns viem's shortMessage, else the first line of message, else your fallback, bounded to 220 characters so an RPC dump cannot push the dismiss button off the toast.

Modal

A modal dialog built on the native <dialog> element, opened with showModal().

import { Modal, ModalHeader, ModalBody, ModalFooter } from "icu-ui-kit"; // or /preact

<Modal open={open} onClose={() => setOpen(false)} labelledBy="confirm-title">
  <ModalHeader id="confirm-title" title="Confirm swap" onClose={() => setOpen(false)} />
  <ModalBody>Swapping 100 PLSX for ~0.4 WPLS.</ModalBody>
  <ModalFooter>
    <button onClick={confirm}>Confirm</button>
  </ModalFooter>
</Modal>

size accepts "sm", "md", "lg", or "xl"; "md" is the default.

onClose is advisory: the dialog never closes itself, open does. That is what lets a host confirm, save, or refuse.

The native element is what makes this installable in the pure-Preact apps. Both source implementations rendered a fixed backdrop through createPortal, and they portalled for a stated reason — they mark #root inert, so the dialog had to live outside it. showModal() puts the element in the browser's top layer and inerts the rest of the document itself, so there is no portal, no #root lookup and no reference-counted inert bookkeeping.

Free from the element: top layer, ::backdrop, inerting the rest, Escape, initial focus, and focus restore on close(). Still implemented here, because the element does not do them:

  • initialFocus — there is no native equivalent (autofocus is an attribute, not a ref).
  • Focus restore on unmount — native restore is a side effect of close(); removing the element while it is open drops focus to <body>.
  • The Tab wrap order — the trap is real, but Firefox 153 cycles first → … → last → <body> → <dialog> → first, so Shift+Tab from the first control lands on the dialog rather than the last one.
  • Escape routing for nested dialogs — see below.

⚠ Nested dialogs and user activation. Escape goes through the platform's close watcher, and close watchers are grouped by user activation. A dialog opened during a real user gesture gets its own group; one opened without a gesture — from an effect, a timer, a websocket message — joins the previous group, and one Escape closes the whole group with cancel.cancelable === false. Measured in Firefox 153. The kit keeps its own open stack and repairs this: a dialog that is not the topmost refuses the close and puts itself back. Open dialogs from a real gesture anyway — it is the only version the platform is on your side for.

Snippet

A read-only value with a copy button.

import { Snippet } from "icu-ui-kit"; // or /preact

<Snippet value={contractAddress} label="contract address" />
<Snippet value={abiJson} label="ABI" multiline />
<Snippet value={abiJson} label="ABI" multiline placement="overlay" />
<Snippet value={voucherCode} label="voucher code" secret />

label is required and it is not decoration — it is the button's accessible name and the subject of the announcement. "Copy" on its own tells a screen-reader user nothing about which of four copy buttons they have landed on.

secret

Masks the value until the reader asks for it — a generated secret shown once: a voucher code, an API key, a recovery phrase. A reveal control appears beside the copy button.

Hiding is real, not cosmetic. While masked the value is absent from the DOM rather than blurred or clipped, because a CSS-obscured secret still lands in a screenshot, a devtools inspection and a text copy of the page. The mask is a fixed twelve bullets and does not track the value's length, which would leak how long the secret is.

Copying still copies the real value while masked. That is the point: the secret can be handed on without being put on screen. The one exception is the manual-copy fallback — when no clipboard path works at all, the value is revealed rather than selected, since selecting a mask would tell the reader they had copied a secret when they had copied bullets.

placement

value where the button goes for
inline-end (default) to the right of the value, always visible a one-liner: an address, a hash, a share link
overlay the field's top-right corner, revealed on hover or focus a multi-line block, or any page where a column of buttons is noise

placement is independent of multiline, on purpose. The two props answer different questions — multiline is about how the value is laid out, placement is about where the control lives — and every combination has a real caller, including an overlay on a single long line and an always-visible button on a wrapped block for a touch screen where nothing hovers. Deriving one from the other would also make <Snippet multiline /> a call site whose button position you cannot predict without reading the kit's source. And the failure directions are not symmetric: guessing overlay wrongly hides a control, guessing inline-end wrongly is only noisier — so the default fails towards "always visible".

The overlay is hidden with opacity, and that is load-bearing. A control that only exists on hover does not exist for a keyboard or a touch user, which is the usual way this pattern ships broken. Three rules prevent it, all in copyfield.css, none of them optional:

  • opacity: 0 — never display: none or visibility: hidden. Those two remove the button from the tab order and from the accessibility tree, so it stops being a control rather than merely going quiet.
  • :focus-within reveals it, so tabbing into the field shows the button that was reachable all along.
  • @media (hover: none) pins it visible. A phone cannot hover, and an overlay that never appears there is a copy button that does not exist.

prefers-reduced-motion drops the fade without ever hiding the button.

Verified against Firefox 153, not read off the CSS — including the part that bites: headless Firefox reports (hover: none) by default, having no pointer device, so a hover check that does not set ui.primaryPointerCapabilities passes for the wrong reason.

The glyph is sized by --icu-snippet-icon-size (20px) rather than inheriting the toast title size, and the hit target is a separate measurement from the icon: --icu-snippet-button-size is 32px for inline-end — over the 24px WCAG 2.5.8 floor — and --icu-snippet-overlay-size is 44px, the enhanced target, which the overlay variant has room for.

overlay reserves that width as constant padding on the field, not inside the value, so nothing reflows when the button fades in and no part of the value can sit under it. The reserve is on the card on purpose: a multiline value is a scroll container, and padding inside it would leave its scrollbar under the button — the top 44px of the thumb unclickable, on exactly the long values that scroll. It is not a space saving over inline-end; it spends slightly more.

The failure path is the point of this component. navigator.clipboard needs a secure context and a user gesture, and this fleet publishes plain HTTP on a LAN — the source app and the reference app both do. Measured in Firefox 153:

origin isSecureContext navigator.clipboard
http://127.0.0.1 true an object; writeText rejects with NotAllowedError without a gesture
http://192.168.x.x false undefined

So on the deployments that matter it is not a rejected promise — it is a TypeError from reading .writeText off undefined, a synchronous throw that a bare .catch() on the promise never sees. Both paths are handled, and both land in the same place: the value is selected so Ctrl+C works, a visible line says so, and announce() says so assertively. The original selected the text and returned, changing nothing on screen and saying nothing, so its honest failure description was "the button does nothing".

document.execCommand("copy") is deliberately not the fallback: it is still present on an insecure origin and returned false when tried there, so reaching for it buys a second silent failure rather than a working copy.

The confirmation goes through the kit's own announcer, so mount <Announcer/> at the root — the same requirement toasts already have. A live region inside the component would be a second thing that speaks, and two regions for one event is a double read-out.

onCopy(copied: boolean) is there for a host that wants a toast as well.

Popover

A surface positioned against a trigger, built on the Popover API: popover="auto" plus showPopover().

import { Popover } from "icu-ui-kit"; // or /preact

const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);

<button ref={trigger} type="button" aria-haspopup="menu" aria-expanded={open}
        aria-controls={open ? "chain-menu" : undefined}
        onClick={() => setOpen((o) => !o)}>
  {chain.name}
</button>

<Popover id="chain-menu" anchorRef={trigger} open={open}
              onRequestClose={() => setOpen(false)} role="menu" label="Chain">
  {chains.map((c) => (
    <button key={c.id} type="button" role="menuitemradio"
            aria-checked={c.id === selected} className="icu-popover-item"
            onClick={() => { pick(c); setOpen(false); }}>
      {c.name}
    </button>
  ))}
</Popover>

This is the non-modal half of the answer Modal gave for modals, and it exists for the same reason: no portal. the source app's version renders a fixed div through createPortal because a dropdown inside a scrolling table is otherwise clipped by an ancestor's overflow: hidden. showPopover() deletes that problem — the element goes into the browser's top layer, and a top-layer element's containing block is the viewport.

Free from the platform, measured in Firefox 153:

  • The containing block. A popover inside an ancestor carrying transform: translateZ(0), overflow: hidden and contain: paint all at once lands at the coordinates it is given and is not clipped. A plain position: fixed sibling in that same ancestor is both offset and clipped. That difference is the portal's entire job.
  • Painting above everything, including z-index: 2147483647.
  • Light dismiss on an outside pointerdown. There is deliberately no outside-click handler in the component — two things racing to close one surface is how you get a menu that flickers.
  • Escape, with the ordering right: a menu open inside a modal <dialog> takes the first Escape and leaves the dialog up.
  • One open at a time — opening a second auto popover closes the first.
  • Focus restore on Escape, to whatever was focused before it opened.

Implemented here, because the platform does not:

  • Position, and the flip when there is no room below. See below on anchor positioning.
  • Re-positioning on scroll. Viewport coordinates go stale: after scrolling 300px the anchor had moved to y = -74 and the surface had not moved at all.
  • Focus on open. Unlike showModal(), showPopover() does not move focus.
  • Focus restore on a light dismiss — the platform restores on Escape only, and leaves focus on <body> otherwise.
  • Closing when focus leaves. Tab walks straight out of an open popover.
  • Arrow-key movement between the items.

⚠ The trigger must be a <button>, and here is why. Light dismiss runs on pointerdown, so a trigger that toggles the menu is hit twice by one click: the platform closes the popover, then the trigger's own handler re-opens it. Measured, the event log for one click on a hand-rolled trigger is beforetoggle open→closed → beforetoggle closed→open → toggle open→open — the menu never closes from its own button, and nothing throws or warns. The component's fix is the platform's own: it sets popoverTargetElement on the anchor, which exempts it from light dismiss, with popoverTargetAction = "hide" so the browser never opens anything the open prop does not know about. popoverTargetElement exists on a button and an input, and not on a <div>.

⚠ onRequestClose is NOT advisory, and that is the one place this differs from Modal on purpose. Modal refuses the browser's close so a host can confirm before discarding; here that is impossible, because beforetoggle for a close has cancelable === false. By the time you hear about it, the surface is gone.

Positioning is a measured offset, not CSS Anchor Positioning — and not because it is missing here. Firefox 153 reports CSS.supports("anchor-name", "--x"), position-anchor, top: anchor(bottom) and position-try-fallbacks all true. It is that the fleet's users are not all on Firefox 153, and a menu that piles up in the middle of the viewport on one browser is a worse failure than a measured offset is on any of them. placeAnchored in src/dom.ts is the only thing that has to go when the floor moves.

Popover is a surface, not a menu. It positions, opens, closes and moves focus; role, the items and their semantics are the caller's, exactly as in the source app — a component that also decided what a menu item is would be escaped by the first caller who wanted menuitemradio. .icu-popover-item, .icu-popover-item-sub, .icu-popover-separator and .icu-popover-empty are classes for your own markup, so the visuals are shared even though the semantics are not.

ContextMenu

A right-click menu. It is a thin layer over Popover — the item vocabulary plus one behaviour — and not a second menu engine, the same relationship Drawer has to Modal.

import { ContextMenu, useContextMenu } from "icu-ui-kit";

const menu = useContextMenu();

<tr onContextMenu={(e) => { e.preventDefault(); menu.openAt(e.clientX, e.clientY); }}>…</tr>

<ContextMenu
  open={menu.open}
  x={menu.x}
  y={menu.y}
  onRequestClose={menu.onRequestClose}
  label="Row actions"
  items={[
    { label: "Copy address", onSelect: () => copy(row.address) },
    { label: "Nothing to copy", onSelect: () => {}, disabled: true },
    { separator: true },
    { label: "Delete", danger: true, onSelect: () => remove(row.id) },
  ]}
/>

Why it is a Popover and not a portal. The obvious build is createPortal to document.body at a fixed x/y, which is what the harvested version did in five separate copies. Popover is in the top layer, so it escapes a clipping or transformed ancestor with no portal at all — and the real trigger sites are inside transformed panels, exactly where an absolutely positioned portal child drifts. It also already had light dismiss, Escape, one-open-at-a-time, the arrow-key ring and focus restore; the copies each implemented some of that.

A separator is { separator: true }, not a magic string. The harvested version used "---" in the items array. A typo in a magic string renders a row whose label is two dashes and nothing anywhere complains.

Selecting a row closes the menu. All five harvested call sites ended every action with a manual close, and forgetting it left a menu open over the thing it had just acted on.

A disabled row is aria-disabled, never the native attribute. FOCUSABLE in src/dom.ts excludes a natively-disabled button, so such a row would drop out of the arrow-key ring and the menu would appear to skip it — a user arrowing down a menu is entitled to discover that a row exists and cannot be used.

Scroll closes it, where an anchored Popover re-places instead. A point is not a trigger: it is where the cursor was at one instant, so re-placing pins the menu to the screen while the row it describes slides out from under it.

anchorRef opens it against an element instead of a point, for a menu raised by a button rather than a right-click — TabStrip uses it that way. Pass exactly one of anchorRef or x/y.

RadioGroup

Radio options can include secondary text without folding it into the option's accessible name:

<RadioGroup
  label="Routing strategy"
  name="routing-strategy"
  value={strategy}
  onChange={setStrategy}
  options={[
    { value: "best", label: "Best", description: "Highest block, then lowest latency" },
    { value: "fallback", label: "Fallback", description: "Priority order with failover" },
  ]}
/>

The description remains inside the option's click target and is connected with aria-describedby; assistive technology receives it as a description rather than as part of the radio's name.

Select

A Popover-hosted listbox, not a native <select> — a reversal of an earlier, deliberate decision, made for a specific reason below.

import { Select } from "icu-ui-kit"; // or /preact

<Select
  label="Chain"
  value={chainId}
  onChange={setChainId}
  loading={chains === undefined}
  options={[
    { value: "1",   label: "PulseChain",         group: "Mainnets" },
    { value: "369", label: "PulseChain Testnet", sublabel: "v4", group: "Testnets" },
  ]}
/>

Selects fit their content by default. Add fullWidth when replacing a control that spans its container.

This used to be a native <select>, and the case against ever changing that was strong — four reasons, in ascending order of weight, kept here because the reversal has to answer every one of them, not just the first:

  1. Its own doc comment said not to — "use a NATIVE <select> wherever the options are plain strings".
  2. Nobody used it, at the time. That has changed: a fleet app wanted this control's option list to look like ComboBox's (below), and an OS-drawn popup cannot be restyled to look like anything — there is no styling path that satisfies that ask without dropping the native element.
  3. The ARIA in a hand-rolled listbox is usually wrong. The abandoned first attempt at this had exactly the failure modes hand-rolling predicts: role="listbox" on the menu with options inside a nested plain <div> (the listbox owning a generic child instead of options), no aria-controls, no aria-activedescendant, no type-ahead, an unconditional aria-label={label} that goes unnamed when label is omitted. This rewrite is what getting it right looks like: role="option" rows are direct (or role="group"-wrapped) children of the role="listbox" panel; the trigger — a <button role="combobox">, not the listbox itself — carries real aria-controls and aria-activedescendant; type-ahead exists (below). Focus never leaves the trigger while the panel is open — the ARIA "select-only combobox" pattern — so Popover is given focusOnOpen={false} and arrowKeys={false}, both defaults built for a menu, not a listbox.
  4. The platform is still better at some of this, and the trade is accepted anyway, not hidden. name/required no longer put the value into a native form submission — there is no <select> or hidden input backing them; read value and submit it yourself if you need that. The mobile OS picker is gone with no replacement. Type-ahead has a replacement: this file implements its own (press a letter to jump to the next match, same as a native <select>).

What it costs beyond that, stated plainly: SelectOption.label is a string, so an icon per row is gone, and sublabel becomes trailing text on the same line rather than a second line. If you need rich rows, use Popover with role="menu" and menuitemradio instead — a still-easier pattern to get right than a combobox.

It also fixes a papercut the source app recorded, unrelated to the rewrite: a value that matches no option renders the placeholder rather than rendering blank. loading and "resolved but empty" are distinct states with distinct copy, because an empty list means "not loaded yet", which is not the same as "none".

There is no more --icu-select-scheme token — it existed only to tint an OS-drawn popup that no longer exists.

ComboBox

A free-text field with filtered suggestions — the value is NOT constrained to options, which is the one thing that actually distinguishes it from Select.

import { ComboBox } from "icu-ui-kit"; // or /preact

<ComboBox
  label="Type"
  value={type}
  onChange={setType}
  placeholder="Unknown"
  options={standardTypes.map((name) => ({ value: name, label: name }))}
/>

Built on the same Popover-hosted listbox as Select — the ARIA "combobox with list autocomplete" pattern — and, deliberately, on the SAME CSS CLASSES: .icu-select-wrap (the field, the chevron), .icu-select-menu, .icu-select-option, .icu-select-group (the suggestion panel). Nothing in combobox.css restyles any of those; it adds exactly one class, .icu-combobox, for the one real difference — a typed <input> in place of a trigger <button>. That sharing is a structural guarantee that the two controls stay visually identical wherever they overlap, not a promise to keep two stylesheets in sync by hand.

Typing filters options (case-insensitive substring match on label + sublabel) and opens the panel; picking a suggestion sets value to its value and closes; typing past every match just keeps value as typed — nothing forces a selection. There is no separate type-ahead here, because typing already is the search.

matchWidth (default true) pins the suggestion panel to the field's own width — the fleet's requirement, not a suggestion. Pass "min" for a floor that a longer row can still grow past, or false for content width.

Unlike Select, name/required on ComboBox DO reach a native form submission — it renders a real <input>, not a trigger button, so there is nothing platform-specific standing in the way.

Tabs

import { Tabs, TabPanel } from "icu-ui-kit"; // or /preact

<Tabs items={items} selected={tab} onSelect={setTab} label="Manage networks" idPrefix="network" />
<TabPanel idPrefix="network" tabKey={tab}>…</TabPanel>

Roving tabindex, role="tablist", arrow keys with Home/End and wrapping, and the aria-controls/aria-labelledby pairing between the two halves. Render only the selected panel: keeping the others mounted and visually hidden is a screen-reader user reading four tabs' worth of content at once.

Activation is automatic by default — an arrow key moves focus and selects, which is what the source app does and what every existing call site is written against. activation="manual" moves a focus cursor and waits for Enter or Space, and it is not decoration: automatic activation means arrowing from the first tab to the fourth selects all four on the way past, which for a panel that fetches on selection is three wasted round trips and three announcements. APG allows both and says to prefer manual exactly when activation is expensive. Manual needs no key handling at all — the tabs are real <button>s, so the browser already turns Enter and Space into a click.

Two changes from the source app:

  • Focus moves by DOM position, not by looking up a generated id. the source app builds a selector out of the item key and CSS.escapes it — but the id it is escaping was written into the markup unescaped, so the two only agree for keys that needed no escaping in the first place.
  • TabPanel is tabIndex={0}. A panel whose content is not focusable is otherwise unreachable from the keyboard and its content cannot be scrolled.

TabStrip

A tablist whose tabs can be renamed, removed and added — saved views, sheet pages, workspaces. Pair it with the same TabPanel that Tabs uses.

import { TabStrip, TabPanel } from "icu-ui-kit";

<TabStrip
  items={views}                       // { key, label: string, locked? }
  selected={viewId}
  onSelect={setViewId}
  label="Views"
  idPrefix="views"
  onRename={(key, name) => rename(key, name)}
  onClose={(key) => remove(key)}
  onAdd={() => create()}
/>
<TabPanel idPrefix="views" tabKey={viewId}>…</TabPanel>

Why it is a separate component and not three more props on Tabs — the short answer is that ARIA does not permit the obvious implementation, and this was checked against axe-core's rule definitions rather than assumed:

  • tablist's only permitted owned role is tab, so any focusable element inside the tablist — a per-tab kebab button, or an <input> swapped in for the tab being renamed — is an aria-required-children violation.
  • tab is childrenPresentational, so a focusable child of a tab is a second, independent violation (nested-interactive).

Both obvious builds are therefore invalid. The rename field and the menu button have to live outside the tablist whichever component owns them — and once that is true, extending Tabs shares no markup while forking its one clean <button role="tab"> path into two shapes. Tabs is untouched by this component; TabPanel is reused verbatim.

So the rename field is an overlay, not a replacement. It is an absolutely positioned sibling of the tablist, laid over the tab it renames. The tab underneath stays a valid, correctly-labelled, still-selected role="tab" throughout — which is what makes three things fall out for free: TabPanel's aria-labelledby never dangles mid-rename, the marker needs no rename special case, and arrow keys typed into the field cannot reach the tablist's key handler because the field is not inside it.

One shared options button, not one per tab. It always acts on the selected tab and its accessible name says which (Options for Whales). Right-clicking any unlocked tab opens the same menu against that tab — right-click does not select, matching what it replaces.

F2 renames the focused tab. Right-click is not a keyboard-reachable gesture, so an affordance offered only there is an affordance keyboard users do not have. locked items opt out of rename, delete and the menu entirely.

There is no Delete/Backspace accelerator, deliberately. Deleting a tab is irreversible and unconfirmed; one stray keystroke on a focused element is not a good way to lose one. A host that wants confirmation wraps onClose itself.

label is a string here, where TabItem.label is a node — rename has to seed a text field from it and hand a string back. A caller who wants icons or rich markup in tab labels wants Tabs.

Drawer

An edge-anchored slide-over. It is a Modal — this component renders one and adds no modal behaviour whatsoever.

import { Drawer } from "icu-ui-kit"; // or /preact

<Drawer open={open} onClose={close} title="Recent transactions"
        titleId="tx-drawer-title" side="right"
        footer={<button onClick={goToAll}>View all</button>}>
  <TransactionList items={recent} />
</Drawer>

Separate component, not a Modal prop, and here is the argument. Everything a drawer needs behaviourally — the top layer, inerting the rest, Escape, the focus trap, focus restore on close and on unmount, initialFocus, the open-stack repair, the advisory onClose — is already in Modal and is not reimplemented. Count the differences and they are all presentation: sizing, the slide instead of a fade, square corners against the hugged edge, and env(safe-area-inset-*) padding so a full-height sheet clears the iOS home indicator.

So why not <Modal side="right">? Because --icu-modal-width would start meaning two things depending on another prop, --icu-modal-max-height would mean nothing for two of the four sides, and the component's documentation would grow a conditional through the middle. Modal is "a modal box in the middle"; Drawer is "a modal sheet against an edge"; each sentence is complete.

The counter-argument gets its due: a caller could already write <Modal className="icu-drawer icu-drawer--right">. Three things earn the wrapper anyway — the class pair is not discoverable and half of it is easy to forget (right-edge layout with the left-edge transition is a real, silent mistake); side is one value rather than two coupled strings; and the wrapper carries markup, not just a class — a header, a body that scrolls, and a footer that stays put.

The test of whether that decision holds: if Drawer ever needs modal behaviour Modal does not have, it was the wrong split and the two should be one component with a prop. twinParity.test.tsx asserts it has not started — no <dialog>, no effect, no listener, in either tree.

All four edges. The side class sets --icu-drawer-enter-x / --icu-drawer-enter-y and one shared rule does the transform, reusing the direction vocabulary .icu-toast-stack--* already established rather than inventing a second one.

FloatingPanel

A draggable, resizable window — several at once, over a page that stays live.

import { FloatingPanel } from "icu-ui-kit";

<FloatingPanel
  title="Holders"
  titleId="holders-title"
  onClose={() => close(id)}
  initialPosition={{ x: 120, y: 90 }}
  initialSize={{ width: 640, height: 420 }}
  minSize={{ width: 360, height: 240 }}
  zIndex={stack.indexOf(id) + 40}
  onActivate={() => bringToFront(id)}
  footer={<button type="button" onClick={refresh}>Refresh</button>}
>
  <HoldersTable address={address} />
</FloatingPanel>

Why this is not a Modal preset, the way Drawer is. Modal is <dialog>+showModal() and Popover is popover="auto", both because they want the top layer — where z-index does not apply, the page behind is inerted, and light dismiss is free. Every one of those is wrong here. A floating panel is one of several on screen at once, the page behind it stays interactive (that is the entire point of a panel rather than a dialog), and the host decides which one is in front. The top layer cannot express any of that, so this is a plain position: fixed surface with a real z-index. Drawer could be a preset because it wanted nothing Modal lacked; this wants the opposite of what Modal provides.

The gesture never goes through React. Drag and resize write to the element directly on every pointermove and commit one state update on pointerup — sixty re-renders a second of a panel with a table inside it is what that avoids. useDraggablePanel, useResizablePanel and useFloatingPanel are exported for a caller who wants its own chrome on the same behaviour.

Drag writes translate, not transform, and the distinction is load-bearing: the entry animation transitions transform (a scale), so a drag writing the same property would be fed through that 180ms ease and the panel would rubber-band behind the cursor. Two properties, two jobs.

Stacking is the host's. The component takes a zIndex and reports onActivate on pointerdown; it never promotes itself, because a panel that did would fight every other panel doing the same, and the list of who is in front is state the host already owns.

Accessibility, stated plainly: drag and resize are pointer-only. A keyboard user can open, read, act on and close a panel and reach every control in it, but cannot move or resize it — the same gap a native OS window has without an explicit move mode. Everything that changes something lives in the header, body or footer and is reachable. The panel is role="dialog" with aria-labelledby and deliberately no aria-modal: its absence is how ARIA spells non-modal, and aria-modal="false" is worse than omitting it because some assistive tech reads the attribute's presence as true whatever its value. There is no focus trap and no focus steal on mount — a data panel opening in the background must not take focus off what the user was doing.

There is no open prop, and so no exit animation. Visibility is mounting: every real consumer drives it from a list it owns. @starting-style gives the entry with no script; nothing can animate an element that has already unmounted. Alert documents the same trade for the same reason.

position: fixed is measured against the viewport, so a host that renders a panel inside an ancestor with transform, filter, contain or will-change: transform will see the coordinates land somewhere else — that ancestor becomes the containing block. Render panels near the root.

Accordion

import { Accordion } from "icu-ui-kit"; // or /preact

<Accordion title="What is a nonce?" name="faq">
  An account's transaction counter.
</Accordion>

A native <details>/<summary>, kept native, and the source app's own reasoning is the right one: the browser supplies the semantics, the keyboard handling, the open state and — the part no scripted accordion has — in-page find. Chrome and Firefox expand a closed <details> when its text matches a Ctrl+F search. On an FAQ page that is the difference between the content being findable and not.

name is the platform's exclusive accordion: sections sharing one behave as a radio set, with no state, no controller component and no effect. Firefox 130+; scripts/verify-firefox.mjs checks it rather than trusting it, and a browser without it degrades to independent sections.

defaultOpen is named default because it is one — the element owns the state afterwards. onToggle(open) is there to persist it.

useDisclosure is a separate export, and not part of this component. It is open/closed state for a dialog, drawer or menu — surfaces where the state has to live outside the element because something across the tree opens them. A <details> owns its own, so reaching for the hook here would replace working element state with a controller that must be kept in sync with it. The type is exported as Disclosure so it cannot collide with this component's name in an import list.

Pagination

<Table … bottomContent={<Pagination page={page} total={pages} onChange={setPage} />} />

A <nav> of buttons with aria-current="page" on the active one, which is what makes the current page announceable at all. It paginates nothing — it holds no state and slices no array; it renders numbers and reports which was pressed. That is why it goes into Table's bottomContent rather than being built in: a table with pagination welded on cannot do server-side paging, infinite scroll, or no paging at all. the source app made that call and it is right.

Changed from the source app: an ellipsis that hides exactly one page is now that page (page 4 of 12 was 1 … 3 4 5 … 12, where the "…" is exactly as wide as the "2" it replaced); siblings is a prop rather than a hardcoded ±1; and every accessible name is overridable, matching Toasts, so a localising host does not fork the component to translate "Page 3".

pageRange(page, total, siblings) is exported, because the collapse rule is the only interesting thing in the component and a host doing its own layout should not have to re-derive it.

Table

import { Table, type TableColumn } from "icu-ui-kit"; // or /preact

<Table
  label="Saved contracts"
  columns={columns} rows={rows} rowKey={(r) => r.address}
  loading={isPending} error={error}
  loadingSheet={tableSheet}
  emptyContent="No contracts saved yet."
  rowHeight={36}
  sort={sort} onSortChange={setSort}
  bottomContent={<Pagination page={page} total={pages} onChange={setPage} />}
/>

⚠ A shell, not a table engine, and that is a design constraint rather than an unfinished feature. It does not sort, filter, paginate or virtualize. sort is a value you pass in, onSortChange reports that a header was pressed, and the rows render in the order given — always. Where those needs actually strain, the answer is @tanstack/react-table; the source app already uses it well, with column groups, frozen columns, server-side sort and CSV export over @tanstack/react-virtual. A kit component with its own sorting would be a worse version of that which consumers then have to escape.

loadingSheet is a capture of the real table body. During an initial load the header stays visible and the captured body renders in one state cell. Existing rows remain visible during background refreshes and failures. rowHeight controls loaded rows.

useTableSort — client-side sorting, as three props

Table's sort is controlled, which server-side sorting and TanStack Table both need. But most fleet tables hold a few dozen rows already in memory, and each was hand-rolling the same useState + useMemo + comparator lookup:

import { Table, useTableSort } from "icu-ui-kit/preact";

const COMPARE = {
  title: (a, b) => (a.title || "").localeCompare(b.title || ""),
  // Negative ids are real; a lexical sort puts "-100" between "-1" and "0".
  chat_id: (a, b) => a.chat_id - b.chat_id,
};

const sorting = useTableSort(chats, COMPARE);
<Table rows={sorting.rows} sort={sorting.sort} onSortChange={sorting.onSortChange} … />

It handles the three things each hand-rolled copy had to remember separately:

  • It copies before sorting. Array.prototype.sort is in place and the array is usually owned by a query cache — sorting it directly mutates data other components are rendering, with no state change to make them re-render. That bug stays invisible until a second component reads the same query.
  • A sortable column with no comparator is left alone, not thrown on. Action columns get marked sortable by accident, and a page that dies over a cosmetic mistake is worse than one that simply does not reorder.
  • undefined rows — the pending state — yield [], and sort is undefined rather than null, which is what Table's prop wants.

useDisclosure — open/closed state, upstream-shaped

const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
<Modal open={isOpen} onClose={onClose}>…</Modal>

The field names are upstream's, deliberately: nine frontends in this fleet still import useDisclosure from upstream, and matching the destructure means their call sites survive the swap untouched.

⚠ onOpenChange is dual-mode. upstream calls it both as a setter (onOpenChange(false)) and, from a modal's own dismiss, as a toggle with no argument. Supporting only one moves the bug into every consumer, so both work.

The type is Disclosure, not Accordion — that name is already the component.

variant="flush" — a table inside a panel you already drew

Table draws its own card: background, border, radius. Nested inside a host panel that becomes two borders, two radii and a doubled inset at the seam.

<div className="card">
  <Table variant="flush" … />
</div>

⚠ className cannot do this — it lands on the scroll container by design, which is inside the card. That is why this is a prop. Defaults to "card", so no existing call site changes.

Composing with TanStack, without depending on it

T is opaque: the component never reads a field off a row, it only calls rowKey(row) and column.render(row). So hand it TanStack's own row objects and adapt the sort state at the seam:

const table = useReactTable({ data, columns: defs, state: { sorting },
  onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel() });

<Table
  rows={table.getRowModel().rows}
  rowKey={(r) => r.id}
  columns={defs.map((d, i) => ({
    key: d.id, label: d.header, sortable: true,
    render: (r) => flexRender(d.cell, r.getVisibleCells()[i].getContext()),
  }))}
  sort={sorting[0] && { column: sorting[0].id,
                        direction: sorting[0].desc ? "descending" : "ascending" }}
  onSortChange={(s) => setSorting([{ id: s.column, desc: s.direction === "descending" }])}
/>

The vocabulary here is ARIA's ("ascending"/"descending") rather than TanStack's (desc: boolean) on purpose: it goes straight into aria-sort with no mapping, and the mapping that does exist belongs where the caller already knows which library they picked. TanStack is not a dependency of this kit and must not become one.

getKeyValue was deliberately not harvested. the source app exports upstream's getKeyValue(row, key): unknown, which nine call sites used inside a render prop — and every one of them immediately writes as any to put the type back: render: (row) => getKeyValue(row, "name") as any. With a typed render: (row: T) => … the expression is row.name: shorter, checked, and no import.

Loading and Bones

import { Bones, Loading } from "icu-ui-kit";
import sheet from "./bones/PositionList.json";

<Loading pending={isPending} error={error} skeleton={<Bones sheet={sheet} />}>
  <PositionList positions={positions} />
</Loading>

Loading renders the supplied captured layout while pending, explicit error content on failure, and its children otherwise. Set hasData to retain cached content during a refresh or subsequent error. The pending state supplies aria-busy and one status announcement.

Capture and verify layouts with the packaged CLI:

icu-bones capture bones.config.json
icu-bones check bones.config.json

The manifest defines the page origin, generated stylesheet, responsive profiles, capture selectors, and output sheets. Text follows its rendered line boxes, while painted parent boxes retain their background, border, radius, and shadow. BROWSERLESS_URL and BROWSERLESS_TOKEN select the Browserless v2 service; BONES_ORIGIN may override the manifest origin. check fails when a generated sheet or stylesheet is stale.

Bones renders captured profiles without runtime measurement. Horizontal geometry scales with the captured container, while vertical geometry remains fixed. Reduced-motion settings stop the pulse without removing its space.

Motion

Three switches, answering different questions. They compose; the user's always wins.

switch whose where scope
prefers-reduced-motion the user's CSS everything, always honoured
data-icu-motion="off" the host's a DOM attribute on any ancestor that subtree, kit or not
configureMotion + the motion prop the developer's JS the kit, or one component
import { configureMotion } from "icu-ui-kit";

// Opt the whole fleet OUT at boot: every kit component renders still…
configureMotion({ enabled: false });

// …and opt one back IN where the movement carries meaning.
<Tabs items={views} selected={id} onSelect={setId} label="Views" idPrefix="v" motion />

motion={false} renders data-icu-motion="off" on that component's root, which is the same attribute a host would put on an ancestor — so there is one mechanism in the stylesheet, not two. The attribute is written only to turn motion off, never "on": an explicit "on" would be a second spelling of the default, and it would not be silenced by a host's ancestor switch, which is exactly the thing a host switch must be able to do.

⚠ configureMotion is read at render, not subscribed. It is a boot-time setting — call it before mounting. Making it reactive would put a subscription in every component in the kit to serve a value that changes at most once in a page's life.

⚠ It does not report whether something will actually animate. The other two switches live in CSS, where this cannot see them and must not: a JS answer would go stale the moment the user changed their system preference.

What moves, and why each one earns it

component movement the reason it is not decoration
Tabs, TabStrip the selected marker travels to the new tab it carries the eye from the old selection to the new one; the distance is measured, so a neighbouring tab is quicker than one across the bar, and a resize cuts instead of sliding because nothing was selected
Pagination the page pill travels same argument, same helpers, same easing token
Alert, EmptyState, FloatingPanel, Popover, Modal, Drawer, Toasts entry they appear because something happened; EmptyState's is deliberately the calmest of them, because it is usually the second thing to occupy that space after a skeleton, and a pop there reads as an error
Select, ComboBox the caret rotates on open tracks the real open state now — Popover is this kit's own, so the page always knows when it closed. (Superseded note: this used to rotate on focus, not open, because a native <select>'s OS-drawn popup never told the page when it closed; that workaround is gone with the native element.)
ContextMenu the row highlight fades, in faster than out arriving on a row should feel immediate; letting it trail on leave makes a fast arrow-key run read as one movement instead of a strobe
TabStrip the rename field fades and scales in place it is a mode change on one tab, not a movement between two — an editor gliding across the bar is one you cannot type into yet
Accordion, Switch, Chip, Button, Input, Snippet, TokenIcon, Bones state and load transitions each documented in its own part

Styling

The fleet is split across Tailwind v3 and v4, and four apps are on upstream while the rest are plain, so the kit depends on Tailwind not at all and uses no @apply. dist/styles.css is real CSS: every value is a custom property with a working fallback, so it looks correct with zero configuration and looks like your app once you map your tokens onto it.

/* the reference app: --brand is RGB channels, for Tailwind's <alpha-value> syntax */
:root {
  --icu-ink:   rgb(var(--brand));
  --icu-good:  rgb(var(--brand));
  --icu-focus: rgb(var(--brand) / 0.9);
}

/* the source app: a dark accent, so the foreground token is --brand-ink */
:root {
  --icu-panel: #0f0f10;
  --icu-ink:   rgb(var(--brand-ink));
  --icu-line:  rgb(var(--brand-edge));
}

Turning motion off

<html data-icu-motion="off">   <!-- the whole app -->
<div data-icu-motion="off">    <!-- or one region -->

Put the attribute on any ancestor and every component below it stops animating. It is independent of prefers-reduced-motion — either one is enough; the OS setting belongs to the user and this one belongs to the host.

An attribute, not a token, because a duration of 0s is not the same thing as no transition: at 0s an element still renders @starting-style's start state, so a dialog would fade in from nothing instead of simply being there. The switch mirrors each part's reduced-motion block exactly — same selectors, same transition: none — and each part carries its own copy, because no part may override a selector it does not declare.

It removes travel, not state. The pagination pill and the tab marker still move to the current item — they are the answer to "which page am I on" — they just cut instead of travelling. Sections still open. Bones keep their box.

Stylesheet parts

src/styles.css was 26,419 B in one file, and CSS cannot tree-shake — so an app that wanted only Bones shipped the whole toast deck too. The stylesheet was the one part of the kit whose cost did not track what the app used.

Two columns, because one of them lies. Roughly 70% of this stylesheet is comments — every custom property is documented next to the rule that reads it — so a raw byte count mostly measures prose. The rules column is what a minifier leaves behind and what the split is actually about.

import bytes rules only contains
icu-ui-kit/styles.css 83,416 27,383 everything — still the default
icu-ui-kit/styles/base.css 16,216 1,144 tokens, .icu-sr-only, spinner, icon button, reduced motion
icu-ui-kit/styles/toast.css 16,783 6,922 the stack, the deck, all eight placements
icu-ui-kit/styles/bones.css — — Bones and the Loading error line
icu-ui-kit/styles/modal.css 6,625 1,946 the modal dialog
icu-ui-kit/styles/drawer.css 7,274 2,748 the edge drawer — also needs dialog.css
icu-ui-kit/styles/popover.css 8,357 2,951 the anchored surface and its row classes
icu-ui-kit/styles/select.css 8,895 4,244 the Select/ComboBox chevron and the shared option-list panel
icu-ui-kit/styles/combobox.css 1,335 190 the ComboBox field only — also needs select.css
icu-ui-kit/styles/snippet.css 3,038 1,270 the copy card and its fallback hint
icu-ui-kit/styles/tabs.css 3,859 1,571 the tablist, tabs and panel
icu-ui-kit/styles/accordion.css 3,330 1,412 the <details> section
icu-ui-kit/styles/pagination.css 3,473 1,785 the page selector
icu-ui-kit/styles/badge.css — — notification-count overlays
icu-ui-kit/styles/table.css, icu-ui-kit/styles/tokenicon.css, icu-ui-kit/styles/switch.css, icu-ui-kit/styles/chip.css, icu-ui-kit/styles/empty.css, icu-ui-kit/styles/alert.css, icu-ui-kit/styles/address.css, icu-ui-kit/styles/button.css, icu-ui-kit/styles/input.css 6,695 3,215 the table shell — also needs bones.css
icu-ui-kit/styles/navbar.css — — responsive navigation and mobile drawer layout
icu-ui-kit/styles/tabstrip.css 3,256 — the editable tab bar — also needs tabs.css
icu-ui-kit/styles/floatingpanel.css 6,488 — the draggable, resizable window
icu-ui-kit/styles/aura.css 333 1 the generative Aura avatar
icu-ui-kit/styles/chart.css 4,800 — the plotted Chart — declares no tokens of its own
icu-ui-kit/styles/progress.css 2,142 — the determinate Progress bar
icu-ui-kit/styles/amount.css 1,918 — Amount, plus the AmountField balance row and shortcuts — also needs input.css
icu-ui-kit/styles/checkbox.css 4,409 7 the Checkbox box, tick and mixed state
icu-ui-kit/styles/radio.css 3,414 8 the RadioGroup circle, label and description
icu-ui-kit/styles/slider.css 4,558 4 the range Slider track, thumb and readout

base.css is required by every other part. It carries the shared rules the others reuse — .icu-sr-only, .icu-icon-button, .icu-spinner — and it is where the whole variable table is documented. Each part says so in its own header too.

import "icu-ui-kit/styles/base.css";      // always
import "icu-ui-kit/styles/bones.css";

Two parts require a third as well as base.css, and each says so in its own header: drawer.css needs modal.css (a drawer IS a <dialog>, and every rule in it is an override of one there) and table.css needs bones.css because Table renders Loading and Bones.

Beyond that the order carries no meaning: no selector is declared in more than one part, each part's prefers-reduced-motion block overrides only selectors that same part declares, and where one part restyles another's element — which is only drawer.css — it does so through a compound selector (.icu-modal.icu-drawer) so specificity rather than source order decides. src/__tests__/stylesSplit.test.ts asserts all three, so the parts stay load-order independent.

src/styles.css is generated by scripts/build-styles.mjs — it is the parts concatenated, byte for byte, and the same test fails if the committed aggregate has drifted from them. Edit the part that owns the rule, then npm run styles.

Every variable

Surfaces and text

variable default used by
--icu-panel #18181b toast surface
--icu-panel-2 #27272a bone tint
--icu-line #3f3f46 toast border, spinner track
--icu-text #f4f4f5 toast title, icon-button hover
--icu-muted #a1a1aa description, dismiss control
--icu-ink #006FEE the brand hue — Button's solid fill, Slider's track, links, spinner head
--icu-focus #d4d4d8 focus ring

Status

variable default used by
--icu-good #17c964 success glyph
--icu-bad #f31260 error glyph
--icu-warn rgb(251 191 36 / 0.9) warning glyph, Loading's error copy
--icu-info #60a5fa info glyph

Shape

variable default used by
--icu-radius 14px toast corner
--icu-radius-sm 8px bone corner
--icu-shadow 0 25px 50px -12px rgb(0 0 0 / 0.6) toast elevation
--icu-badge / --icu-badge-on tone-derived notification badge fill and text
--icu-navbar-z 40 sticky navigation stacking order
--icu-navbar-gap / --icu-navbar-item-gap 1rem / 0.875rem navigation spacing
--icu-navbar-max-width / --icu-navbar-height / --icu-navbar-padding 80rem / 4rem / 1rem navigation geometry

Toast stack geometry

variable default used by
--icu-toast-inset 1rem distance from the viewport corner
--icu-toast-width 20rem stack width
--icu-toast-max-width 90vw narrow-viewport clamp
--icu-toast-gap 0.5rem space between toasts
--icu-toast-z 60 stacking order

Modal — icu-ui-kit/styles/modal.css

There is deliberately no --icu-modal-z: showModal() puts the element in the browser's top layer, where z-index has no meaning and nothing in the page can paint over it.

variable default used by
--icu-modal-width 28rem panel width
--icu-modal-max-width calc(100vw - 2rem) narrow-viewport clamp
--icu-modal-max-height calc(100vh - 4rem) tall-content cap; the body scrolls past it
--icu-modal-padding 1.25rem panel padding
--icu-modal-gap 1rem header / body / footer rhythm
--icu-modal-radius 14px panel corner
--icu-modal-backdrop rgb(0 0 0 / 0.6) the ::backdrop wash
--icu-modal-duration 150ms open transition

Anchored menu — icu-ui-kit/styles/popover.css

There is no --icu-popover-z, for the same reason there is no --icu-modal-z. --icu-popover-space is written as an inline custom property by the renderer on every placement pass, so setting it on :root has no effect; it is listed because --icu-popover-max-height is only half the story without it.

variable default used by
--icu-popover-min-width 12rem surface width floor
--icu-popover-max-width min(22rem, 90vw) surface width cap
--icu-popover-max-height 18rem surface height cap
--icu-popover-padding 0.25rem surface padding
--icu-popover-radius 12px surface corner
--icu-popover-shift 0.25rem how far it slides in
--icu-popover-duration 120ms open / close transition
--icu-popover-item-padding-y 0.375rem row padding, block axis
--icu-popover-item-padding-x 0.75rem row padding, inline axis
--icu-popover-item-radius 8px row corner
--icu-popover-space measured room between trigger and viewport edge

Select / ComboBox — icu-ui-kit/styles/select.css, plus icu-ui-kit/styles/combobox.css for ComboBox specifically. combobox.css declares no --icu-* tokens of its own — the field, the chevron and the option-list panel are all select.css's classes, reused rather than restyled (see the "ComboBox" section above), so this is the whole variable surface for both controls.

variable default used by
--icu-select-height 2.25rem control height
--icu-select-padding-x 0.75rem control padding, inline axis
--icu-select-radius 10px control corner
--icu-select-caret-duration 160ms chevron rotate/colour transition

Copy field — icu-ui-kit/styles/snippet.css

variable default used by
--icu-snippet-padding-y 0.5rem card padding, block axis
--icu-snippet-padding-x 0.75rem card padding, inline axis
--icu-snippet-radius 12px card corner
--icu-snippet-gap 0.5rem value to button, and the overlay's reserved column
--icu-snippet-max-height 12rem multiline value cap, then it scrolls
--icu-snippet-icon-size 1.25rem the copy glyph itself, in both placements
--icu-snippet-button-size 2rem inline-end hit target, both axes
--icu-snippet-overlay-size 2.75rem overlay hit target, both axes
--icu-snippet-overlay-duration 120ms the overlay's hover and focus fade

Tabs — icu-ui-kit/styles/tabs.css

variable default used by
--icu-tablist-padding 0.25rem bar padding
--icu-tablist-radius 12px bar corner
--icu-tablist-gap 0.25rem space between tabs
--icu-tab-padding-y 0.375rem tab padding, block axis
--icu-tab-padding-x 0.75rem tab padding, inline axis
--icu-tab-radius 8px tab corner

Drawer — icu-ui-kit/styles/drawer.css

A drawer is a Modal, so it reads every --icu-modal-* above as well. --icu-drawer-enter-x/-y are DERIVED — the .icu-drawer--* side class sets them, exactly as the toast placement classes set --icu-toast-enter-x/-y. --icu-drawer-size is the knob.

variable default used by
--icu-drawer-size 22rem extent along the drawer's own axis
--icu-drawer-max-size calc(100vw - 3rem) small-viewport clamp
--icu-drawer-duration 220ms slide in / out

Accordion — icu-ui-kit/styles/accordion.css

variable default used by
--icu-accordion-padding-y 0.75rem summary and body padding
--icu-accordion-duration 150ms chevron rotation

Pagination — icu-ui-kit/styles/pagination.css

variable default used by
--icu-pagination-gap 0.25rem space between buttons
--icu-pagination-size 2rem button box, both axes
--icu-pagination-radius 8px button corner

Data table — icu-ui-kit/styles/table.css

There is no --icu-table-row-height. rowHeight controls loaded rows; the loading state comes from loadingSheet.

variable default used by
--icu-table-pad-x 0.75rem cell padding, inline axis
--icu-table-pad-y 0.5rem header and slot padding, block axis
--icu-table-empty-pad 1.5rem room around the empty copy

Type

variable default used by
--icu-font-sans inherit the stack
--icu-font-mono ui-monospace, … hash text
--icu-text-size-sm 0.875rem toast title
--icu-text-size-xs 0.75rem description, error copy
--icu-text-size-2xs 0.6875rem hash

Motion and placeholders

variable default used by
--icu-bones-duration 2s pulse period
--icu-bones-still-opacity 0.75 reduced-motion tint
--icu-spinner-size 0.75rem spinner box

Class names

Every class is icu--prefixed and none is named after a utility. A component class called .card, .bones or .sr-only either overrides or is overridden by the host's rule of the same name, and both directions are invisible until someone screenshots it. The full set:

icu-sr-only  icu-spinner  icu-icon-button
icu-toast-stack  icu-toast  icu-toast-glyph{,--success,--error,--warning,--info}
icu-toast-body  icu-toast-title  icu-toast-desc  icu-toast-hash  icu-toast-link
icu-bone  icu-bones-set  icu-bones  icu-bones__bone  icu-loading-error
icu-modal  icu-modal-header  icu-modal-title  icu-modal-body  icu-modal-footer
icu-popover  icu-popover-item  icu-popover-item-sub  icu-popover-separator  icu-popover-empty
icu-select-wrap{,--disabled}  icu-select{,--invalid}
icu-snippet{,--multiline,--inline-end,--overlay}  icu-snippet-value  icu-snippet-button  icu-snippet-hint
icu-tablist  icu-tab{,--selected}  icu-tabpanel
icu-drawer{,--left,--right,--top,--bottom}  icu-drawer-body  icu-drawer-footer
icu-accordion  icu-accordion-summary  icu-accordion-title
icu-accordion-marker  icu-accordion-body
icu-pagination  icu-pagination-page  icu-pagination-step  icu-pagination-gap
icu-table-card  icu-table-top  icu-table-bottom  icu-table-scroll  icu-table
icu-table-th{,--numeric}  icu-table-sort  icu-table-sort-glyph
icu-table-row  icu-table-td{,--numeric}  icu-table-rowbutton
icu-table-state  icu-table-empty

The four icu-popover-* row classes have no component. Popover is a surface and the items are the caller's markup, so those are hooks you put on your own buttons.

Load styles.css before your utility layer: class selectors here have the same specificity as a Tailwind utility, so ties break on source order and you want <Toasts className="mb-20"/> to win.

Notes for pure-Preact consumers

  • No createPortal, anywhere. ⚠ Not because it is unavailable — that claim was wrong and is corrected here. preact/compat ships inside the preact package, so it resolves in a pure-Preact app with no alias and no react dependency; the source app imports createPortal from it in production. The reason is that the platform's top layer is better than a portal: it escapes clipping ancestors without moving the node, so DOM order, event bubbling and context survive. And here a portal is not needed at all: the stack is a position: fixed root <section> mounted at the top of the tree, with no ancestor overflow/transform/contain to escape. Mount it somewhere clipped and that stops being true, so mount it at the root. Modal needs no portal either, and that is the whole reason it is built on the native <dialog>: showModal() puts it in the browser's top layer, which is above every stacking context in the page by definition. Popover is the same trade for a non-modal surface, on popover="auto" — and there the top layer buys something stronger than stacking order: a top-layer element's containing block is the viewport, so it escapes an ancestor's transform and contain as well as its overflow.
  • popover="auto" is spread from a constant, not written out. @types/react@18 has no popover in its JSX surface — it arrived with the React 19 types — while Preact's has had it since 10.19. Writing the attribute literally type-checks in one tree and fails in the other, and the twins have to be the same characters. POPOVER_AUTO in src/dom.ts is the workaround and the reason it exists.
  • No forwardRef. IconButton takes elementRef, in both trees, because ref is a reserved prop that never reaches a function component. Modal's initialFocus is typed structurally as { readonly current: HTMLElement | null } rather than either framework's RefObject — Preact's has a mutable current and is therefore invariant, so a useRef<HTMLButtonElement>(null) would not be assignable to a RefObject<HTMLElement> parameter.
  • If you add a text input around this kit, remember pure Preact wants onInput, not onChange — without compat, onChange is the DOM change event and fires on blur. ComboBox follows this rule (its Preact tree uses onInput); the blur-delayed behaviour is specific to text and range inputs, which is why Select — a <button>, not a text field, since its rewrite — never faces this choice at all.
  • src/dom.ts is where framework-free logic lives. The focus predicate, the popover helpers and the placement maths are imported by both trees rather than copied into each, so there is nothing there for the source-parity test to prove — the drift is impossible instead of merely detected.

Packaging

"sideEffects" is ["**/*.css"] rather than the flat false of the sibling kit. The JS is genuinely side-effect-free and the claim is the same; the exception exists because a bundler told sideEffects: false will happily drop import "icu-ui-kit/styles.css" as unused, and an app whose toasts render unstyled has no error to go on.

Development

npm install
npm test          # vitest, jsdom
npm run typecheck
npm run build     # tsup → dist/ (ESM + d.ts) for all three entries, + styles
npm run styles    # just the stylesheet: src/styles/*.css → src/styles.css + dist/

The browser half of the suite is separate, because jsdom cannot answer it:

node scripts/verify-firefox.mjs   # needs a real Firefox

jsdom 30 defines HTMLDialogElement and puts no methods on it — no showModal, no close — and has no inert support at all, so "the rest of the page is genuinely inert" and "Escape reaches only the topmost dialog" are unprovable there. It implements none of the Popover API either: showPopover, hidePopover, popover and popoverTargetElement are all undefined. src/__tests__/dialogShim.ts and src/__tests__/popoverShim.ts supply the missing elements so the components can be unit-tested; each says in its own header what it deliberately cannot model. scripts/verify-firefox.mjs drives a real Firefox over WebDriver BiDi (zero dependencies — Node's global WebSocket is the whole client) and pins the browser facts underneath both: the top layer, the containing block that replaces the portal, light dismiss, close-watcher grouping, and the invoker exemption.

dist/ is committed. The package is consumed as a git dependency, and a consumer installing with --ignore-scripts would never run prepare — so shipping the build output keeps the package usable without asking anyone to relax their install flags or put a build toolchain in their image. Rebuild and commit it alongside any source change.

License

MIT — see LICENSE.