LeCodesdocs

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

TypeScript
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

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

Full 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 pass overlayColor: null for none),
  • animates on show()/hide() — a 200 ms fade unless you swap the pose with transition() (the exit plays with commit: false and 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.
TypeScript
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): this
TypeScript
const 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 wired

transition(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.

TypeScript
// 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():

TypeScript
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)
TypeScript
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 finger

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

TypeScript
widget.style({ overlayColor: "rgba(0, 0, 0, 0.5)" })   // Color | null
widget.onOverlayTap(cb: () => void): this

overlayColor 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

TypeScript
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

TypeScript
widget.animateTo({ overlayColor, opacity, transform, …, duration?, delay?, commit?: boolean })
widget.animateFrom({ … })              // animate from the given values to the current style

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

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

TypeScript
// 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 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 — content never re-lays out.
  • Drag (native hosts): finger snap with velocity, rubber-band past the top detent, and the scroll handoff — a UIScrollable inside 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

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