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

TypeScript
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.

TypeScript
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 / height in that box (safe-area values like "safe-top" and "safe-bottom" work). A widget with no position of its own is centred.
  • children is the content, widgets is what floats over it: a screen's widgets are never among its children, and setContent / append on the screen leave them alone. parent of a widget is its screen.
  • A widget among the children of a UIColumn, a UIScreen or 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.

TypeScript
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 of widgets.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 when screen.class.offline = true (a class on the screen reaches its widgets). A state block keeps the declared order.
  • isShown is the widget's own display. A widget hidden with its page (another tab is current) or its covered screen still reads true: 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().

TypeScript
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.

TypeScript
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 with transition());
  • closes itself on a scrim tap or a back press (dismissible(false) turns both off — forced-choice dialogs).
TypeScript
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.

TypeScript
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().

TypeScript
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 fade
TypeScript
const 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().

TypeScript
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" })   // Color | null
widget.onOverlayTap(cb: () => void): this
TypeScript
const 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.

Note

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.

TypeScript
widget.onTouchStart(ev => …)           // ev.track({...}) for drag gestures
widget.onBack(cb: () => void): this    // a back press while the widget is shown
TypeScript
const 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.

Note

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.

TypeScript
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 UIModal

An action sheet is just children:

TypeScript
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:

TypeScript
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 a UIScrollable inside for long content). With detents() the box is the highest detent (height + bottom: 0 are 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 sets overlayColor: null.
  • setDetent() while the sheet is shown moves it and fires onDetent; while it is hidden it only picks the detent the next show() 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 UIScrollable inside 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

TypeScript
// ✗ 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 instance

See also