UIWidget
A floating overlay above the current page: always position-fixed relative to the device, drawn
above the destination's content. Use it for bottom sheets, dialogs, toasts-with-actions, floating
players — and it's the sanctioned way to put UI over a Scene. Create a widget once at module
scope and reuse it — visibility is imperative. For a standard dialog, reach for the prewired
UIModal below instead of assembling scrim + animations + close handlers by hand; for a
draggable bottom sheet with snap positions, UIBottomSheet.
show() opens the widget as a global overlay above every destination: it stays up across
navigation until you hide() it. Navigation never touches an unattached widget — a modal with
overlayColor blocks interaction underneath anyway, so close it from its own buttons /
onOverlayTap / onBackPressed.
To make a widget part of a page instead — a HUD over a Scene — attach it first:
attachTo(owner) puts the widget on that page's layer, so it shows and hides with the page,
rides the page's transition, and a screen pushed on top covers it.
At a glance
const dialog = UIWidget(
UIText("Delete item?").style({ fontWeight: 700, fontSize: 18, color: "black" }),
UIButton(UIText("Delete").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 8, height: 40, justifyContent: "center" })
.onClick(() => { doDelete(); dialog.hide() }),
)
.style({ left: 24, right: 24, top: "40%", p: 16, gap: 12, borderRadius: 16,
bgColor: "white", overlayColor: "rgba(0,0,0,0.5)" })
.onOverlayTap(() => dialog.hide())
.onBackPressed(() => dialog.hide())
// anywhere, on any screen:
dialog.show()Creating & visibility
UIWidget(...children: (UINodeChild | UINodeChild[])[]): UIWidget
widget.show(): void // mount as a global overlay (hidden until then)
widget.hide(): void // unmount
widget.isShow: boolean // getter — currently shown?
widget.attachTo(owner: Presentable | null): this // put the widget on the owner's page layerFull container surface: .append / .insert / .remove / .setContent, .style,
.onLayout. Position it with top / left / right / bottom / width / height — the
coordinates are device-screen space (safe-area values like "safe-bottom" work).
attachTo(owner) makes the widget belong to that destination (a Scene, a UIScreen, …): it
is visible only while the owner is presented — hiding when a pushed screen covers the owner,
coming back when a pop reveals it — and it animates together with the owner during transitions.
Attach before show() (while the widget is hidden); it's sticky until attachTo(null), which
makes the widget a global overlay again. A HUD for an ARScene can be attached before open()
so it's in place from the first frame.
UIModal — dialogs with the boilerplate built in
For the standard dialog, use UIModal instead of assembling the pattern by hand. It is a
UIWidget (same styling, children, touch and attachTo surface) that additionally:
- has the scrim by default (
overlayColor: "rgba(0, 0, 0, 0.5)"— override it, or passoverlayColor: nullfor none), - animates on
show()/hide()— a 200 ms fade unless you swap the pose withtransition()(the exit plays withcommit: falseand unmounts when done, so the style is intact for the next show), - closes itself on a scrim tap and on the Android back button —
dismissible(false)turns both off for forced-choice dialogs.
UIModal(style?, children?): UIModal // same overloads as UIWidget
modal.show(): void // mount + entrance transition (no-op while open)
modal.hide(): void // exit transition, then unmount (no-op while closing)
modal.isOpen: boolean // true from show() until hide() starts
modal.transition(hidden): this // replace the show/hide animation — see below
modal.onOpen(cb: () => void): this // after show() mounts it
modal.onClose(cb: () => void): this // when closing starts — scrim tap, back, or hide()
modal.dismissible(enabled: boolean): thisconst confirm = UIModal(
UIText("Delete item?").style({ fontWeight: 700, fontSize: 18, color: "black" }),
UIButton(UIText("Delete").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 8, height: 40 })
.onClick(() => { doDelete(); confirm.hide() }),
).style({ left: 24, right: 24, top: "40%", p: 16, gap: 12, borderRadius: 16, bgColor: "white" })
confirm.show() // anywhere — scrim, fade-in, back button and scrim tap already wiredtransition(hidden) — swap the show/hide animation
hidden is the off-screen pose: show() animates from it, hide() animates to it, and its
duration (ms, default 200) times both directions plus the deferred unmount. The pose replaces
the default { opacity: 0 } wholesale — a slide without a fade is just the slide. The scrim
keeps its own fade unless the pose drives overlayColor itself. transform lengths are px
(percentages aren't supported) — slide by at least the modal's own height.
// a static bottom panel is one line of difference from a dialog:
const panel = UIModal(…)
.style({ left: 0, right: 0, bottom: 0, height: 400, borderRadius: 20, bgColor: "white" })
.transition({ transform: "translateY(480px)", duration: 250 })
panel.show()For a draggable sheet (finger snap between positions), don't hand-build it — use
UIBottomSheet below.
UIPopover — anchored menus
For a dropdown, context menu, or tooltip, use UIPopover — a UIModal whose scrim defaults to
"transparent" (invisible but still intercepting: an outside tap dismisses, and nothing
underneath can scroll while it's open, so the anchor can't move out from under it) and whose
position comes from the anchor you pass to show():
UIPopover(style?, children?): UIPopover // same overloads as UIWidget/UIModal
popover.show(anchor?): void // anchor: any element, or a raw { x, y } point (long-press menus)
popover.hide(): void // exit transition, then unmount — plus everything UIModal has
// (isOpen, onOpen/onClose, dismissible, transition — default is
// a 120 ms fade)const item = (label: string, action: () => void) =>
UIButton(UIText(label).style({ color: "white" }))
.style({ height: 40, px: 12, justifyContent: "flex-start" })
.onClick(action)
const menu = UIPopover(
item("Rename", () => { menu.hide(); rename() }),
item("Delete", () => { menu.hide(); remove() }),
).style({ width: 220, borderRadius: 12, bgColor: "#222", py: 4 })
moreButton.onClick(() => menu.show(moreButton)) // anchored to the button
rowButton.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })) // at the fingerPlacement is automatic and computed once per show: below the anchor's left edge (4 px gap),
flipped above when there's no room, clamped 8 px inside the viewport (the flip/clamp is refined
on the popover's first layout, inside the entrance fade — you never see the correction). The
popover attaches itself to Presentable.current, so it hides with the page it opened on and
rides its transition; an explicit attachTo(owner) before show() is respected. Anchored
popovers don't follow their anchor — position-once is the platform convention.
Under the hood this is exactly the getBoundingClientRect() + attachTo recipe (see
overview.md) — hand-roll
it only when you need placement logic the component doesn't offer.
Modal behavior — overlayColor
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" }) // Color | null
widget.onOverlayTap(cb: () => void): thisoverlayColor adds a full-screen scrim behind the widget that blocks all taps underneath —
this is what turns a widget into a modal (dialog / bottom sheet). "transparent" is invisible
but still intercepts; null (the default) removes the layer entirely. A tap on the scrim fires
onOverlayTap — usually () => widget.hide().
Touch & back button
widget.onTouchStart(ev => …) // ev.track({...}) for drag gestures (sheet dragging)
widget.onBackPressed(cb: () => void) // Android back while the widget is up — usually hide()Widgets are one of the three touch-receiving elements (with UIButton and UIScreen) — see
Pointer events.
Exit animations — animateTo with commit: false
widget.animateTo({ overlayColor, opacity, transform, …, duration?, delay?, commit?: boolean })
widget.animateFrom({ … }) // animate from the given values to the current styleanimateTo normally writes the target values into the widget's style when it starts. For an
exit animation that's wrong — the next show() would start from the faded-out values. Pass
commit: false to play the animation without persisting it, and hide() when done:
const close = () => {
widget.animateTo({ opacity: 0, commit: false, duration: 250 })
setTimeout(() => widget.hide(), 250) // style still has opacity 1 for the next show()
}UIBottomSheet — draggable multi-detent sheet
A widget pinned to the bottom edge. Content-sized by default — as tall as its children
(capped at the screen), one position, drag down to dismiss: the action-sheet shape, zero
configuration. Add detents for the map-app model — snap positions the user drags between on
native hosts. Everything modal-shaped (scrim, onOpen/onClose, scrim-tap and back-button
dismissal, dismissible(false)) works exactly like UIModal.
// content-sized: an action sheet is just children
const actions = UIBottomSheet(rows).style({ bgColor: "white", borderRadius: 20 })
actions.show()
// detents: the map-app model
const sheet = UIBottomSheet(
UIColumn().style({ width: 50, height: 6, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 12 }),
UIScrollable(results),
)
.style({ bgColor: "white", borderRadius: 20 })
.detents([0.3, 0.6, 1]) // fractions of screen height, ascending
.onDetentChange(i => { ... }) // every settle: a finger snap or setDetent()
sheet.show() // slides in to the current detent (index 0 initially)
sheet.setDetent(2) // programmatic, animated
sheet.hide() // slides out, scrim fades, unmounts- Sizing: without
detents()the sheet's box is its content (maxHeight: "100%"; put aUIScrollableinside for long content). Withdetents()the box is the highest detent (height+bottom: 0are set for you); lower detents show the top slice. Detent changes and the drag are pure translation — content never re-lays out. - Drag (native hosts): finger snap with velocity, rubber-band past the top detent, and the
scroll handoff — a
UIScrollableinside scrolls normally at the top detent, and pulling down from its top hands the gesture to the sheet. On web the sheet is static; detent changes animate. - Dismissal: dragging below the lowest detent closes the sheet (fires
onClose);dismissible(false)makes it collapse to the lowest detent instead — the persistent map-style sheet. - Create it once at module scope, like every widget.
(A fully custom gesture is still possible with a plain UIWidget + onTouchStart
claim: "pan-y" tracking — see touch — but reach for UIBottomSheet
first: the native drag physics can't be matched from JS.)
Pitfalls
// ✗ re-creating the widget per screen / per show
const openSheet = () => UIWidget(...).show() // leaks a new widget every call
// ✓ create once at module scope, show()/hide() the same instance
// ✗ expecting it to appear on creation
const w = UIWidget(...) // hidden until w.show()See also
- UIScreen & Router — the screen stack widgets float above
- Pointer events & gestures —
ev.track(),claim - Styling —
animate,animateTo, transforms