Real stories:How a football-school coach shipped his apps in one week soon

Overlays

Step 7 of the UI track. Not everything deserves its own screen: a delete confirmation, picking "repeat on weekdays", a three-item menu on a row — these are layers on top of the current page. In LeCodes they share one root — UIWidget — and come in three ready shapes: UIModal for dialogs, UIBottomSheet for sheets from the bottom edge and UIPopover for menus anchored to a button. Plus toast — the cheapest feedback there is.

A layer above the page

UIWidget is a floating element above the current page, positioned in screen coordinates (top / left / right / bottom; safe-area values work). It's invisible until show() is called, and it's created once at module scope — visibility is imperative, no need to recreate the widget per show. The overlayColor style adds a scrim behind it that intercepts taps — that's what makes a widget modal.

For a standard dialog you don't assemble this by hand: UIModal is the same widget but already with a scrim, an entrance animation, and dismissal on a tap outside and on the back button. One dialog for the whole app — and which habit we're deleting is remembered by a signal:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", color: "#34c759" },
  { id: 2, title: "Read 20 pages", color: "#ff9f0a" },
  { id: 3, title: "Stretching", color: "#af6bff" },
])
const pending = signal<Habit | null>(null)          // what we're deleting

const Btn = (label: string, bg: string, onTap: () => void) =>
  UIButton(UIText(label).style({ fontSize: 15, fontWeight: 600 }))
    .style({ flex: 1, height: 44, borderRadius: 12, bgColor: bg, $pressed: { opacity: 0.7 } })
    .onClick(onTap)

const confirm = UIModal(
  UIText("Delete the habit?").style({ fontSize: 18, fontWeight: 700 }),
  UIText(() => `“${pending.value?.title ?? ""}” and all its history will be gone.`).style({ color: colors.muted, fontSize: 14 }),
  UIRow(
    Btn("Cancel", colors.line, () => confirm.hide()),
    Btn("Delete", colors.danger, () => {
      habits.value = habits.value.filter((h) => h !== pending.value)
      confirm.hide()
    }),
  ).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })

const HabitRow = (h: Habit) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIButton(UIText("Delete").style({ color: colors.danger, fontSize: 13, fontWeight: 600 }))
      .style({ height: 32, px: 12, borderRadius: 16, bgColor: colors.bg, $pressed: { opacity: 0.6 } })
      .onClick(() => { pending.value = h; confirm.show() }),
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 10, gap: 12, alignItems: "center" })

const screen = UIScreen(
  UIText("Habits").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
  UIText(() => (habits.value.length === 0 ? "All gone — a clean slate" : "")).style({ color: colors.muted, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()

Tap "Delete" on any row: the scrim dims the screen, the dialog fades in, a tap outside or the back button closes it. None of that had to be written.

  • show() opens the widget as a global layer above every screen: it survives navigation until you call hide(). To make a layer belong to a page (a HUD over a scene, a hint on one specific screen) — widget.attachTo(owner) before show().
  • The dialog text is a binding to the pending signal: one dialog instance, the content changes before each show.
  • modal.dismissible(false) turns off dismissal on outside tap and back — for a forced choice. modal.onOpen / modal.onClose are hooks, modal.isOpen the state.
  • A custom animation is modal.transition({ transform: "translateY(480px)", duration: 250 }): show animates from that pose, hide to it.
  • On a bare UIWidget the scrim is overlayColor, a tap on it is onOverlayTap, and an animated exit is animateTo({ opacity: 0, commit: false }) + hide() on a timer. UIModal does all of that for you.

A sheet from the bottom

Picking from a few options on a phone is a sheet from the bottom edge. UIBottomSheet with no configuration is exactly that: content-sized, a scrim, swipe down to dismiss (with real finger physics on native hosts; in the web simulator the sheet is static). Everything modal — scrim, back, dismissible — works like UIModal:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const OPTIONS = ["Every day", "On weekdays", "On weekends", "Three times a week"]
const repeat = signal(OPTIONS[0])

const Option = (label: string) =>
  UIButton(
    UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (repeat.value === label ? colors.accent : "#ffffff") }),
    UIText("✓").style({ color: colors.accent, fontSize: 16, opacity: () => (repeat.value === label ? 1 : 0) }),
  )
    .style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
    .onClick(() => { repeat.value = label; sheet.hide() })

const sheet = UIBottomSheet(
  UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
  UIText("Repeat").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
  OPTIONS.map(Option),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })

const screen = UIScreen(
  UIText("Stretching").style({ fontSize: 32, fontWeight: 800 }),
  UIText("Habit settings").style({ color: colors.muted, fontSize: 15 }),

  UIButton(
    UIText("Repeat").style({ fontSize: 16, flexGrow: 1 }),
    UIText(() => repeat.value).style({ color: colors.muted, fontSize: 15 }),
    UIText("›").style({ color: colors.muted, fontSize: 22, ml: 8 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, mt: 16, $pressed: { opacity: 0.6 } })
    .onClick(() => sheet.show()),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })

screen.open()
  • Without detents() the sheet is as tall as its content (never taller than the screen — wrap a long list in a UIScrollable inside). With .detents([0.3, 0.6, 1]) it's the map-app model: positions as fractions of the screen height, the finger snaps between them, setDetent(i) from code, onDetentChange the event.
  • Swiping below the lowest position dismisses the sheet; with dismissible(false) it collapses to the lowest instead — the persistent map-style panel.
  • The "handle" bar on top is just a narrow UIColumn, not an SDK element.
  • Like every widget — one instance at module scope; the rows inside are bound to the repeat signal, so the check mark moves by itself.

A menu on a button

Three actions on a row are no reason for a half-screen sheet. UIPopover is a modal with a transparent scrim (a tap outside dismisses, nothing scrolls underneath) and a position from an anchor: menu.show(button) places the menu below the button — above it if there's no room below — and always inside the screen. From a long press it can open at the finger: menu.show({ x: ev.clientX, y: ev.clientY }).

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", color: "#34c759" },
  { id: 2, title: "Read 20 pages", color: "#ff9f0a" },
  { id: 3, title: "Stretching", color: "#af6bff" },
])
let target: Habit | null = null                       // the row the menu is open for

const Item = (label: string, color: string, action: (h: Habit) => void) =>
  UIButton(UIText(label).style({ fontSize: 15, color }))
    .style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
    .onClick(() => { menu.hide(); if (target) action(target) })

const menu = UIPopover(
  Item("Duplicate", "#ffffff", (h) => {
    habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (copy)` }]
  }),
  Item("Move to top", "#ffffff", (h) => {
    habits.value = [h, ...habits.value.filter((x) => x !== h)]
  }),
  Item("Delete", colors.danger, (h) => {
    habits.value = habits.value.filter((x) => x !== h)
    toast("Habit deleted")
  }),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })

const HabitRow = (h: Habit) => {
  const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
    .style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
    .onClick(() => { target = h; menu.show(more) })     // the anchor is the button itself
  return UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    more,
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}

const screen = UIScreen(
  UIText("Habits").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()
  • The position is computed once per show — the menu doesn't follow its anchor; that's the platform convention (and the reason the scrim blocks scrolling).
  • The "⋯" button is captured in the more variable to pass it to show(more); target is a plain variable: there's one menu, and the row changes before every show.
  • toast("…") is the host's native pop-up message: one line, no styles, disappears by itself. "Undo delete" already needs a widget with a button.
  • A long press is button.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })); on real hosts it swallows the click that would follow, so onClick and onLongPress coexist on one button.

The whole screen

Three overlays in one tracker screen: a "⋯" menu on the row, from it — the "Repeat" sheet and the delete dialog, a toast after actions:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

type Habit = { id: number; title: string; color: string; repeat: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Morning run", color: "#34c759", repeat: "Every day" },
  { id: 2, title: "Read 20 pages", color: "#ff9f0a", repeat: "On weekdays" },
  { id: 3, title: "Stretching", color: "#af6bff", repeat: "Every day" },
])
const target = signal<Habit | null>(null)
const patch = (h: Habit, changes: Partial<Habit>) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, ...changes } : x))
}

const OPTIONS = ["Every day", "On weekdays", "On weekends"]
const sheet = UIBottomSheet(
  UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
  UIText("Repeat").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
  OPTIONS.map((label) =>
    UIButton(UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (target.value?.repeat === label ? colors.accent : "#ffffff") }))
      .style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
      .onClick(() => { if (target.value) patch(target.value, { repeat: label }); sheet.hide() }),
  ),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })

const confirm = UIModal(
  UIText("Delete the habit?").style({ fontSize: 18, fontWeight: 700 }),
  UIText(() => `“${target.value?.title ?? ""}” will be gone along with its history.`).style({ color: colors.muted, fontSize: 14 }),
  UIRow(
    UIButton(UIText("Cancel").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.line }).onClick(() => confirm.hide()),
    UIButton(UIText("Delete").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.danger }).onClick(() => {
      habits.value = habits.value.filter((h) => h !== target.value)
      confirm.hide()
      toast("Habit deleted")
    }),
  ).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })

const Item = (label: string, color: string, action: () => void) =>
  UIButton(UIText(label).style({ fontSize: 15, color }))
    .style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
    .onClick(() => { menu.hide(); action() })
const menu = UIPopover(
  Item("Repeat…", "#ffffff", () => sheet.show()),
  Item("Duplicate", "#ffffff", () => {
    const h = target.value
    if (h) { habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (copy)` }]; toast("Duplicated") }
  }),
  Item("Delete", colors.danger, () => confirm.show()),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })

const HabitRow = (h: Habit) => {
  const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
    .style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
    .onClick(() => { target.value = h; menu.show(more) })
  return UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIColumn(
      UIText(h.title).style({ fontSize: 16, fontWeight: 600 }),
      UIText(h.repeat).style({ color: colors.muted, fontSize: 13 }),
    ).style({ gap: 2, flexGrow: 1 }),
    more,
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}

const screen = UIScreen(
  UIText("Habits").style({ fontSize: 32, fontWeight: 800 }),
  UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })

screen.open()

All three overlays are created once and share one target signal — "the row we're working on right now". The menu opens the sheet or the dialog after closing itself: each layer has its own scrim, and there's always exactly one on screen. Note that target is a signal here rather than a variable, because the dialog text and the check mark in the sheet are bound to it.

What you learned

  • UIWidget is a layer above the page: created once, show() / hide(), overlayColor makes it modal, attachTo(owner) ties it to a page.
  • UIModal is a dialog with scrim, animation and dismissal on outside tap / back out of the box; dismissible(false) for a forced choice, transition() for a custom pose.
  • UIBottomSheet is a sheet sized by content or with detents(); a swipe dismisses it.
  • UIPopover is a menu from an anchor: show(button) or show({ x, y }); the position is computed once.
  • toast() is a native message with no styles; bind overlay content to a "current target" signal instead of recreating widgets.

Reference: UIWidget, UIModal, UIPopover, UIBottomSheet · Toast · Pointer events.

Next: polish — entrance animations, press feedback, safe areas and everything that separates "works" from "feels good".