- TypeScript 69.5%
- CSS 25.8%
- JavaScript 4.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| dist | ||
| scripts | ||
| src | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| RELEASING.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
| tsup.preact.config.ts | ||
| vitest.config.ts | ||
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 ispopover="auto". Each of those replaced a hand-rolled version, and each replacement deleted a portal, az-indexor an ARIA pattern nobody could test.Selectwas 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.
reactandpreactare 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.
pendingnever 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,
errorincluded, 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
msalways wins, for any status. It is step 1 of the resolution order. ms: 0pins 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 (autofocusis 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— neverdisplay: noneorvisibility: 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-withinreveals 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 setui.primaryPointerCapabilitiespasses 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: hiddenandcontain: paintall at once lands at the coordinates it is given and is not clipped. A plainposition: fixedsibling 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
autopopover 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 = -74and 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 isbeforetoggle 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 setspopoverTargetElementon the anchor, which exempts it from light dismiss, withpopoverTargetAction = "hide"so the browser never opens anything theopenprop does not know about.popoverTargetElementexists on a button and an input, and not on a<div>.
⚠
onRequestCloseis NOT advisory, and that is the one place this differs fromModalon purpose.Modalrefuses the browser's close so a host can confirm before discarding; here that is impossible, becausebeforetogglefor a close hascancelable === 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:
- Its own doc comment said not to — "use a NATIVE
<select>wherever the options are plain strings". - 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. - 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), noaria-controls, noaria-activedescendant, no type-ahead, an unconditionalaria-label={label}that goes unnamed whenlabelis omitted. This rewrite is what getting it right looks like:role="option"rows are direct (orrole="group"-wrapped) children of therole="listbox"panel; the trigger — a<button role="combobox">, not the listbox itself — carries realaria-controlsandaria-activedescendant; type-ahead exists (below). Focus never leaves the trigger while the panel is open — the ARIA "select-only combobox" pattern — soPopoveris givenfocusOnOpen={false}andarrowKeys={false}, both defaults built for a menu, not a listbox. - The platform is still better at some of this, and the trade is accepted
anyway, not hidden.
name/requiredno longer put the value into a native form submission — there is no<select>or hidden input backing them; readvalueand 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. TabPanelistabIndex={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 istab, so any focusable element inside the tablist — a per-tab kebab button, or an<input>swapped in for the tab being renamed — is anaria-required-childrenviolation.tabischildrenPresentational, 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
Drawerever needs modal behaviourModaldoes not have, it was the wrong split and the two should be one component with a prop.twinParity.test.tsxasserts 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.
useDisclosureis 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 asDisclosureso 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.
sortis a value you pass in,onSortChangereports 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 appalready 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.sortis 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
sortableby accident, and a page that dies over a cosmetic mistake is worse than one that simply does not reorder. undefinedrows — the pending state — yield[], andsortisundefinedrather thannull, which is whatTable'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.
getKeyValuewas deliberately not harvested. the source app exports upstream'sgetKeyValue(row, key): unknown, which nine call sites used inside a render prop — and every one of them immediately writesas anyto put the type back:render: (row) => getKeyValue(row, "name") as any. With a typedrender: (row: T) => …the expression isrow.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/compatships inside thepreactpackage, so it resolves in a pure-Preact app with no alias and noreactdependency;the source appimportscreatePortalfrom 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 aposition: fixedroot<section>mounted at the top of the tree, with no ancestoroverflow/transform/containto escape. Mount it somewhere clipped and that stops being true, so mount it at the root.Modalneeds 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.Popoveris the same trade for a non-modal surface, onpopover="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'stransformandcontainas well as itsoverflow. popover="auto"is spread from a constant, not written out.@types/react@18has nopopoverin 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_AUTOinsrc/dom.tsis the workaround and the reason it exists.- No
forwardRef.IconButtontakeselementRef, in both trees, becauserefis a reserved prop that never reaches a function component.Modal'sinitialFocusis typed structurally as{ readonly current: HTMLElement | null }rather than either framework'sRefObject— Preact's has a mutablecurrentand is therefore invariant, so auseRef<HTMLButtonElement>(null)would not be assignable to aRefObject<HTMLElement>parameter. - If you add a text input around this kit, remember pure Preact wants
onInput, notonChange— without compat,onChangeis the DOM change event and fires on blur.ComboBoxfollows this rule (its Preact tree usesonInput); the blur-delayed behaviour is specific to text and range inputs, which is whySelect— a<button>, not a text field, since its rewrite — never faces this choice at all. src/dom.tsis 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.