UIWidget — dialogs, bottom sheets, menus and HUDs
A UIWidget is an overlay inside the app — a dialog, a bottom sheet, a menu, a HUD — that belongs
to a screen or a scene and floats over it: screen.widgets(dialog), scene.widgets(hud). It is not
a home-screen widget: the platform has none (Not available). A
floating element inside a box is an absolute child (position: "absolute"), not a widget.
- ask the user to confirm before deleting something (
UIModal) - show a bottom sheet the user drags between heights (
UIBottomSheet) - open a menu next to the button that was tapped (
UIPopover) - show the score and a pause button over a game scene (
scene.widgets) - dim the screen behind a popup and close it on a tap outside (
overlayColor) - show a loading overlay above every screen
- slide a dialog up from the bottom instead of fading it in (
transition) - close a panel with the Android back button (
onBack)
At a glance
const dialog = UIModal(
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(() => { console.log("deleted"); dialog.hide() }),
).style({ display: "none", left: 24, right: 24, top: "40%", p: 16, gap: 12, borderRadius: 16, bgColor: "white" })
UIScreen(
UIButton(UIText("Delete…").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => dialog.show()),
)
.widgets(dialog) // the screen's dialog, hidden until a tap
.style({ bgColor: "black", p: 24, pt: "safe-top" }).open()Widgets belong to a screen or a scene: widgets(...)
An overlay is given to the destination it floats over — a UIScreen, a Scene, a Scene2D, a
NativeView (a map, a camera preview) or a UIVideo — through that destination's widgets.
UIWidget(...children): UIWidget // same argument forms as UIColumn
screen.widgets(dialog, sheet) // set them; chains: UIScreen(content).widgets(dialog).open()
scene.widgets(() => [hud, paused.value && menu]) // a function: re-run when a signal it reads changes
x.widgets.append(w) / x.widgets.remove(w) // one at a time
x.widgets.list // UIWidget[], bottom to top — a snapshot- A widget is laid out and painted in the box of the outermost presented destination that contains it, absolutely positioned there, above everything else in it. A dialog declared on a tab page covers the tab bar and rides the shell's push and pop.
- Position it with
top/left/right/bottom/width/heightin that box (safe-area values like"safe-top"and"safe-bottom"work). A widget with no position of its own is centred. childrenis the content,widgetsis what floats over it: a screen's widgets are never among itschildren, andsetContent/appendon the screen leave them alone.parentof a widget is its screen.- A widget among the children of a
UIColumn, aUIScreenor any other box throws at insert ("a widget … is not a child"). - A widget has one place: a dialog shared by several screens belongs to none and is shown with
show()alone (below). A module-scope modal given to a screen survives the screen's free, because its handle still owns it.
Show and hide a widget: show() / hide()
A widget shows with its screen unless its style says display: "none"; show() and hide() flip
that display.
widget.show(): void // display: "flex", and on top of the other widgets
widget.hide(): void // display: "none" — it stays where it is, ready for the next show()
widget.isShown: boolean // getter — shown by its own display?show()moves the widget to the end ofwidgets.list: a dialog opened later covers an earlier one and a HUD declared after it.- A state block flips the display from a class too:
UIWidget(...).style({ display: "none", $offline: { display: "flex" } })shows whenscreen.class.offline = true(a class on the screen reaches its widgets). A state block keeps the declared order. isShownis the widget's own display. A widget hidden with its page (another tab is current) or its covered screen still readstrue: that is its place's visibility, not the widget's.
A loading overlay above every screen: show() with no place
A widget that belongs to no screen — a global loader, a dialog opened from anywhere — is shown
above every screen and scene by show() alone, and stays above them while the app navigates
underneath, until hide().
const loader = UIWidget(UIText("Loading…").style({ color: "white", fontSize: 18 }))
.style({ top: 0, bottom: 0, left: 0, right: 0, justifyContent: "center", alignItems: "center", bgColor: "#0009" })
const refresh = async () => {
loader.show() // no place of its own: above every screen and scene
try {
const res = await fetch("https://api.example.com/items")
console.log("items", res.json<unknown[]>().length)
} finally {
loader.hide() // hidden, kept for the next show()
}
}
UIScreen(
UIButton(UIText("Refresh").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => { refresh() }),
).style({ p: 24, pt: "safe-top" }).open()A HUD over a game scene: scene.widgets
The score, a health bar and a pause button over a game are widgets of the scene — never a transparent screen, which would take the scene off screen.
const score = signal(0)
const paused = signal(false)
const scene = new Scene({ skybox: "#202030" })
scene.add(Light.sun(), Mesh.box({ material: Material.lit({ color: "#e33" }), position: [0, 0.5, -2] }))
scene.addEventListener("click", () => { if (!paused.value) score.value++ })
const button = (label: string, onTap: () => void) =>
UIButton(UIText(label).style({ color: "white" }))
.style({ height: 40, px: 16, bgColor: "#000A", borderRadius: 8 })
.onClick(onTap)
const setPaused = (on: boolean) => { paused.value = on; Time.paused = on }
const hud = UIWidget(UIRow(
UIText(() => `Score: ${score.value}`).style({ color: "white", fontSize: 20 }),
button("Pause", () => setPaused(true)),
).style({ justifyContent: "space-between", alignItems: "center" }))
.style({ top: "safe-top", left: 16, right: 16 })
const pauseMenu = UIModal(button("Resume", () => setPaused(false))).dismissible(false)
scene.widgets(() => [hud, paused.value && pauseMenu]) // the menu is there only while paused
scene.open()A Scene2D takes its HUD the same way (scene2d.widgets(hud)). Time.paused freezes the game
(Time); the widgets keep working.
A confirmation dialog before deleting: UIModal
A dialog that asks the user to confirm before an action — delete, discard, sign out — is a
UIModal: a UIWidget with the scrim, the animation and the close-on-tap-outside built in. The
At a glance block is a complete one. On top of a plain widget it:
- comes with a scrim —
overlayColor: "rgba(0, 0, 0, 0.5)"unless you override it; - animates on
show()/hide()and on a state block flipping its display — a 200 ms fade by default, played by the runtime as tracks that never touch the style (swap it withtransition()); - closes itself on a scrim tap or a back press (
dismissible(false)turns both off — forced-choice dialogs).
UIModal(...children): UIModal
modal.show(): void // show + entrance animation (no-op while open)
modal.hide(): void // exit animation, then hidden (no-op while closed)
modal.isOpen: boolean // true from show() until hide() starts
modal.onOpen(cb): this // after show(), or a state block showing it
modal.onClose(cb): this // when closing starts — scrim tap, back, hide(), a state block
modal.dismissible(enabled): this // default true
modal.transition(hidden): this // the off-screen pose (below)A dialog whose display is bound to data (a state block lit by a signal) is dismissible(false) and
routes onOverlayTap / onBack into the data's dismiss, so the data stays what the screen shows.
Slide a dialog up instead of fading: transition(hidden)
transition(hidden) swaps a dialog's fade for another animation. hidden is the off-screen pose:
show() animates from it, hide() animates to it. The default is { opacity: 0, duration: 200 }; a pose replaces it wholesale, so a slide without a fade is just the slide. The scrim keeps
its own fade unless the pose sets overlayColor itself.
const saved = UIModal(
UIText("Saved to your library").style({ color: "black", fontSize: 18 }),
UIButton(UIText("OK").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => saved.hide()),
)
.style({ display: "none", left: 0, right: 0, bottom: 0, p: 24, pb: "safe-bottom", gap: 12, bgColor: "white" })
.transition({ transform: "translateY(300px)", duration: 250 }) // slides up, no fade
UIScreen(
UIButton(UIText("Save").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => saved.show()),
)
.widgets(saved)
.style({ p: 24, pt: "safe-top" }).open()For a panel the user drags, use UIBottomSheet.
A menu next to a button: UIPopover
A dropdown, a context menu or a tooltip next to the element that opened it is a UIPopover: a
UIModal whose scrim defaults to "transparent" (invisible but still intercepting: an outside tap
dismisses, and nothing underneath can scroll while it is open, so the anchor can't move out from
under it) and whose position comes from the anchor passed to show().
UIPopover(...children): UIPopover // same forms as UIWidget
popover.show(anchor?): void // anchor: any element, or a raw { x, y } point (long-press menus)
popover.hide(): void // plus everything UIModal has — the default animation is a 120 ms fadeconst item = (label: string, action: () => void) =>
UIButton(UIText(label).style({ color: "white" }))
.style({ height: 40, px: 12, justifyContent: "flex-start" })
.onClick(() => { menu.hide(); action() })
const menu = UIPopover( // made once, shown next to whatever opened it
item("Rename", () => console.log("rename")),
item("Delete", () => console.log("delete")),
).style({ width: 220, borderRadius: 12, bgColor: "#222", py: 4 })
const more = UIButton(UIText("More").style({ color: "white" }))
.style({ height: 44, px: 16, bgColor: "#333", borderRadius: 8 })
.onClick(() => menu.show(more)) // below the button
const row = UIButton(UIText("Long-press this row").style({ color: "white" }))
.style({ height: 56, px: 16, bgColor: "#1c1c1e" })
.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })) // at the finger
UIScreen(UIColumn(more, row).style({ gap: 12 })).style({ p: 24, pt: "safe-top" }).open()Placement is 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 and clamp are refined on the popover's first layout, inside the entrance fade — the correction is never seen). The popover goes where its anchor is: the anchor's screen, page or surface, found through the tree, so the menu lands where it was opened and goes with it. A popover shown from a point with no parent goes to the app layer, above every screen. Without an anchor, the popover shows where its own style puts it. An anchored popover doesn't follow its anchor afterwards.
Dim the screen behind a popup: overlayColor
overlayColor puts a scrim behind a widget, over its screen's whole box: it dims what is under it,
blocks every tap there, and a tap on it fires onOverlayTap — usually hide().
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" }) // Color | null
widget.onOverlayTap(cb: () => void): thisconst tip = UIWidget(UIText("Swipe a card left to archive it").style({ color: "black" }))
.style({ display: "none", left: 24, right: 24, bottom: "safe-bottom", p: 16, borderRadius: 12, bgColor: "white" })
.style({ overlayColor: "rgba(0, 0, 0, 0.4)" }) // dims the screen, blocks taps under it
.onOverlayTap(() => { console.log("outside"); tip.hide() })
UIScreen(
UIButton(UIText("Show tip").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => tip.show()),
)
.widgets(tip)
.style({ p: 24, pt: "safe-top" }).open()What intercepts is the scrim's presence, not its color: "transparent" is invisible but still
intercepts; null (the default on a plain UIWidget) removes it. The scrims stack in the order of
widgets.list: a dialog opened later is on top, and its scrim covers an earlier dialog and a HUD.
A tap inside a widget never reaches the screen's own touch handler behind it.
Back button and touches on a widget
A panel or a dialog closes on the Android back button through onBack; a UIModal does it itself.
a widget's onBack runs on Android and, on the web, on Escape (and on the browser's
Back while the router is above its first screen). iOS has no back press for a widget — the
system's back swipe is off while a scrim is on screen — and the desktop host and the web player
have none: give a dialog a visible way to close.
widget.onTouchStart(ev => …) // ev.track({...}) for drag gestures
widget.onBack(cb: () => void): this // a back press while the widget is shownconst filters = UIWidget(UIText("Filters").style({ color: "white", fontSize: 18 }))
.style({ display: "none", top: 0, bottom: 0, right: 0, width: 280, p: 24, bgColor: "#1c1c1e" })
.onBack(() => filters.hide()) // the back press closes the panel, not the screen
UIScreen(
UIButton(UIText("Filters").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => filters.show()),
)
.widgets(filters)
.style({ p: 24, pt: "safe-top" }).open()A back press asks the widgets on screen first, in paint order from the top — the app layer's, then
the destination's own and the ones hoisted from its pages — then the destination; a widget without
onBack is passed over, one with it takes the press
(the chain). Widgets are one of
the three touch-receiving elements, with UIButton and UIScreen — see
Pointer events.
A bottom sheet with snap heights: UIBottomSheet
A panel pinned to the bottom edge that the user drags up and down — an action sheet, the results
over a map — is a UIBottomSheet. It is content-sized by default: as tall as its children
(capped at the screen), one position, drag down to dismiss. detents() adds snap heights the user
drags between. The scrim, onOpen / onClose, scrim-tap and back-press dismissal and
dismissible(false) work as on UIModal.
the drag runs on iOS and Android. On the web a drag settles on the nearest detent (no flick, and an inner scroll that is not at its top keeps the drag). The desktop host, the web player and headless tests show a plain bottom panel with no drag — a detents sheet shows its whole box (the highest detent) whatever the detent there.
UIBottomSheet(...children): UIBottomSheet
sheet.detents([0.3, 0.6, 1]): this // fractions of screen height (sorted for you)
sheet.setDetent(index): void // animated; clamped to the detents; fires onDetent
sheet.detent: number // the current detent index (getter)
sheet.onDetent(cb: (index: number) => void): this // every settle: a drag or setDetent()
sheet.show(): void // slides in to the current detent (0 at first)
sheet.hide(): void // slides out, the scrim fades
// + isOpen, onOpen / onClose, dismissible — as on UIModalAn action sheet is just children:
const actions = UIBottomSheet( // content-sized: as tall as its rows
UIButton(UIText("Share").style({ color: "black" })).style({ p: 16 }).onClick(() => actions.hide()),
UIButton(UIText("Delete").style({ color: "#FF4032" })).style({ p: 16 }).onClick(() => actions.hide()),
).style({ display: "none", bgColor: "white", borderRadius: 20, pb: "safe-bottom" })
UIScreen(
UIButton(UIText("Options").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => actions.show()),
)
.widgets(actions)
.style({ p: 24, pt: "safe-top" }).open()A sheet that stays on the screen, with snap heights — the map-app model:
const results = UIColumn(
...["Café Lumen", "Bakery 9", "Tea House"].map(name => UIText(name).style({ color: "black", p: 16 })),
)
const sheet = UIBottomSheet(
UIColumn().style({ width: 40, height: 5, borderRadius: 3, bgColor: "#D9D9D9", mx: "auto", my: 10 }),
UIScrollable(results),
)
.style({ bgColor: "white", borderRadius: 20, overlayColor: null }) // no scrim: the screen stays tappable
.detents([0.25, 0.5, 0.9]) // fractions of the screen height
.dismissible(false) // a drag down stops at the lowest detent
.onDetent(i => console.log("detent", i)) // every settle: a drag or setDetent()
UIScreen(
UIButton(UIText("Expand").style({ color: "white" }))
.style({ height: 44, bgColor: "#333", borderRadius: 8 })
.onClick(() => sheet.setDetent(2)), // animated; clamped to the detents
)
.widgets(sheet) // shown with the screen, at detent 0
.style({ p: 24, pt: "safe-top" }).open()- 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 — the content never re-lays out. - The scrim: a sheet has the modal scrim (
rgba(0, 0, 0, 0.5)), which blocks every tap on the screen under it. A sheet that stays on screen next to live content setsoverlayColor: null. setDetent()while the sheet is shown moves it and firesonDetent; while it is hidden it only picks the detent the nextshow()slides in to.- Drag (iOS, Android): the release snaps to the detent nearest where the flick would carry it;
the sheet stops hard at the top detent (no rubber-band). The scroll hand-off: a
UIScrollableinside scrolls normally at the top detent, and pulling down from its top hands the gesture to the sheet. - Dismissal: dragging below the lowest detent closes the sheet (fires
onClose);dismissible(false)makes it drag with resistance there and settle back on the lowest detent — the persistent map-style sheet. - A persistent sheet on a tab page is laid out over the tab bar, like every widget; it stays while the page's tab is current and comes back with it.
A fully custom gesture is possible with a plain UIWidget + onTouchStart and
claim: "pan-y" tracking (touch), but reach for UIBottomSheet first: its
drag runs natively on the phones.
Pitfalls
// ✗ a widget among the children
UIColumn(UIWidget(...)) // throws: a widget belongs to a destination
UIScreen(content, UIModal(...)) // throws: a screen's children are its content
// ✓ a screen's widget; a floating element of a box is an absolute child
UIScreen(content).widgets(UIModal(...))
UIColumn(UIBox(...).style({ position: "absolute", bottom: 16, right: 16 }))
// ✗ expecting a dialog given to a screen to stay hidden
UIScreen(content).widgets(UIModal(...)) // shown with the screen — a widget shows by default
// ✓ hidden until a tap
UIScreen(content).widgets(UIModal(...).style({ display: "none" }))
// ✗ re-creating the widget per show
const openSheet = () => UIWidget(...).show() // a new widget above everything every call
// ✓ declare it once where it belongs, show() / hide() the same instanceSee also
- UIScreen & Router — the screens widgets float over, the back-press chain
- Scene — opening a 3D scene, the destination of a game HUD
- NativeView — a map or a camera preview with its controls in
widgets(...) - Pointer events & gestures —
ev.track(),claim - Styling —
animate,animateTo, transforms