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

Lists & data

Step 4 of the UI track. With four habits everything fits on the screen. A real tracker has a dozen, wants a strip of days on top, and its check-in history is hundreds of rows. Today — three tools for content that doesn't fit: UIScrollable as the screen body, a horizontal strip, and UIVirtualizedList for long data.

The screen doesn't scroll

The first rule that breaks web habits: UIScreen is a fixed root. It has no scroll position, no scroll events, no pull-to-refresh. Scrolling always lives in a descendant — UIScrollable. The canonical screen is fixed chrome (header, buttons, tab bar) plus one scrolling body with flexGrow: 1:

TypeScript
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

const TITLES = ["Morning run", "Glass of water", "Read 20 pages", "Meditation", "Piano",
  "Stretching", "A walk", "Spanish", "Journal", "Vitamins", "No sugar", "Plan tomorrow"]
const habits = signal(TITLES.map((title, i) => ({ id: i, title, time: `${String(7 + i).padStart(2, "0")}:00` })))

const HabitRow = (h: { title: string; time: string }) =>
  UIRow(
    UIText(h.title).style({ color: "white", fontSize: 16, fontWeight: 600, flexGrow: 1 }),
    UIText(h.time).style({ color: MUTED, fontSize: 13 }),
  ).style({ bgColor: CARD, borderRadius: 16, p: 14, alignItems: "center" })

const screen = UIScreen(
  UIText("Today").style({ color: "white", fontSize: 32, fontWeight: 800 }),
  UIText(() => `${habits.value.length} habits`).style({ color: MUTED, fontSize: 15 }),

  UIScrollable(() => habits.value.map(HabitRow))
    .style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),

  UIButton(UIText("+ New habit").style({ color: "white", fontSize: 16, fontWeight: 700 }))
    .style({ bgColor: "#FF4032", borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
    .onClick(() => {
      const n = habits.value.length
      habits.value = [...habits.value, { id: n, title: `Habit ${n + 1}`, time: "21:00" }]
    }),
).style({ bgColor: "#0e0e12", p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })

screen.open()

Scroll the list in the phone: the header and the button stay put, only the middle moves. What matters here:

  • flexGrow: 1 gives the scroll all the space between the chrome and the button. UIScrollable is the only container with flexShrink: 1 by default, so it shrinks and scrolls instead of pushing the button off-screen. If a wrapper UIColumn appears between it and the screen, that wrapper needs flexShrink: 1 by hand.
  • Function children work here too: UIScrollable(() => habits.value.map(HabitRow)) is the same reactive list as in the state step. No more UISpacer: the scroll takes the remainder itself.
  • gap and padding are properties of the scroll itself; showScrollbar: false hides the bar.
  • Pull-to-refresh — .onRefresh(async () => …) — lives on the scroll as well (it's a no-op in the web simulator, real on a phone). The .onScroll(pos => …) event reports the position in logical px.
Caution

UIScrollable has no scrollTo — its position can't be set from code. When you need programmatic scrolling (to a row, to the end), use the UIVirtualizedList from the section below.

A horizontal strip

The strip of days above the list is the same UIScrollable with scrollDirection: "horizontal". There is no separate "carousel" element: a horizontal scroll is the primitive, and snap can make it rest on child boundaries when you want that. The selected day is the $active class you know from earlier steps:

TypeScript
const ACCENT = "#FF4032"
const CARD = "#1a1a20"
const MUTED = "#8a8a93"

const days = Array.from({ length: 14 }, (_, i) => date().subtract(i, "day"))   // today first
const selected = signal(0)

const DayCard = (d: DateValue, i: number) =>
  UIButton(
    UIText(d.format("ddd")).style({ color: MUTED, fontSize: 12, $active: { color: "white" } }),
    UIText(d.format("D")).style({ color: "white", fontSize: 18, fontWeight: 700 }),
  )
    .style({
      width: 52, height: 64, borderRadius: 14, bgColor: CARD, flexDirection: "column", gap: 2,
      $active: { bgColor: ACCENT, duration: 150 },
    })
    .class({ active: () => selected.value === i })
    .onClick(() => { selected.value = i })

const screen = UIScreen(
  UIText("This week").style({ color: "white", fontSize: 32, fontWeight: 800, px: 20 }),

  UIScrollable(days.map(DayCard))
    .style({ scrollDirection: "horizontal", showScrollbar: false, gap: 8, px: 20, py: 12, flexShrink: 0 }),

  UIText(() => days[selected.value].format("dddd, D MMMM"))
    .style({ color: MUTED, fontSize: 15, px: 20 }),
).style({ bgColor: "#0e0e12", pt: "max(safe-top, 24px)" })

screen.open()
  • The strip's padding sits on the scroll itself (px: 20), not on the screen: that way cards slide under the screen edge instead of being clipped at the content frame. The heading and the caption get their own px.
  • flexShrink: 0 restores normal behavior for the strip — otherwise the vertical layout could squeeze its height.
  • A UIButton with flexDirection: "column" is a card-shaped button: two texts stacked, centering on both axes already built in.
  • A pager of full-screen slides is the same scroll with snap: "start" and width: "100%" children; the current page is onScroll divided by the width. The recipe is in the Containers reference.

Long lists — UIVirtualizedList

Function children and UIScrollable create all rows up front. For a dozen cards that's right; for a year of check-in history it's not: hundreds of elements in the tree cost memory and startup time. UIVirtualizedList mounts only the rows near the visible area (plus a small buffer) and recycles them as you scroll.

Data is fed imperatively: the list is created once, then setData / append / update. Three required config fields: a stable row key, a height estimate and a render function:

TypeScript
const MUTED = "#8a8a93"
type Entry = { id: number; day: string; title: string; done: boolean }

const TITLES = ["Morning run", "Read 20 pages", "Piano", "Stretching"]
const makeEntries = (from: number, count: number): Entry[] =>
  Array.from({ length: count }, (_, k) => {
    const i = from + k
    return { id: i, day: date().subtract(Math.floor(i / 4), "day").format("D MMMM"), title: TITLES[i % 4], done: i % 3 !== 0 }
  })

const total = signal(200)

const list = UIVirtualizedList<Entry>({
  keyOf: (e) => String(e.id),                    // a stable key — not the index
  estimatedHeight: 56,                           // a guess until the first measurement
  render: (e) =>                                 // a pure function of the data item
    UIButton(
      UIText(e.done ? "✓" : "·").style({ color: e.done ? "#34c759" : MUTED, fontSize: 16, width: 20 }),
      UIColumn(
        UIText(e.title).style({ color: "white", fontSize: 15, fontWeight: 600 }),
        UIText(e.day).style({ color: MUTED, fontSize: 12 }),
      ).style({ flexGrow: 1, gap: 2 }),
    )
      .style({ px: 20, height: 56, gap: 12, $pressed: { opacity: 0.6 } })
      .onClick(() => list.update({ ...e, done: !e.done })),   // change a row — through update
})
  .style({ flexGrow: 1 })
  .onEndReached(600, () => {                     // load more near the bottom edge
    list.append(...makeEntries(total.value, 100))
    total.value += 100
  })

list.setData(makeEntries(0, 200))

const screen = UIScreen(
  UIRow(
    UIText("History").style({ color: "white", fontSize: 32, fontWeight: 800, flexGrow: 1 }),
    UIText(() => `${total.value} entries`).style({ color: MUTED, fontSize: 13 }),
  ).style({ px: 20, pb: 12, alignItems: "flex-end" }),
  list,
).style({ bgColor: "#0e0e12", pt: "max(safe-top, 24px)" })

screen.open()

Scroll down — near the end the list tops itself up with another hundred rows and the counter in the header updates. Tap a row — the check mark flips through update. The rules at work here:

  • keyOf is a stable id, not an index: the list matches rows by key across setData, update and removeByKey.
  • render is a pure function of the item. A row can be unmounted and rebuilt at any moment as it leaves and re-enters the window — so it must not read outside mutable state. To change a mounted row, change the data and call .update(item).
  • estimatedHeight matters only until the first measurement; a closer guess just reduces scroll jitter.
  • The list has no intrinsic height — without flexGrow: 1 (or a height) it collapses to zero and shows nothing.
  • onEndReached(threshold, cb) — the px threshold comes first; the callback fires once on entering the zone and again only after the user has scrolled back out of it. Keep your own "loading" / "exhausted" flags.
  • There's also prepend (chat history — no scroll jump), inverted: true (chat: first row at the bottom), scrollTo / scrollToKey / scrollToEnd — the things UIScrollable lacks.

The whole screen

The tracker's "Today" screen in its new layout: a fixed header and day strip, a scrolling list of habits, a pinned button. Theme and typography from the previous step:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const type = {
  h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
  body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
  small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}

type Habit = { id: number; title: string; time: string; color: string; done: boolean }
const PALETTE = ["#34c759", "#ff9f0a", "#5e9eff", "#af6bff", "#ff6b81", "#2fd6c8"]
const TITLES = ["Morning run", "Glass of water", "Read 20 pages", "Meditation", "Piano",
  "Stretching", "A walk", "Spanish", "Journal", "Vitamins", "No sugar", "Plan tomorrow"]
const habits = signal<Habit[]>(TITLES.map((title, i) => ({
  id: i, title, time: `${String(7 + i).padStart(2, "0")}:00`, color: PALETTE[i % PALETTE.length], done: i < 3,
})))
const doneCount = computed(() => habits.value.filter((h) => h.done).length)
const toggle = (h: Habit) => {
  habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}

const days = Array.from({ length: 14 }, (_, i) => date().subtract(i, "day"))   // today first
const selectedDay = signal(0)

const DayCard = (d: DateValue, i: number) =>
  UIButton(
    UIText(d.format("ddd")).style({ ...type.small, fontSize: 12, $active: { color: "#ffffff" } }),
    UIText(d.format("D")).style({ fontSize: 18, fontWeight: 700 }),
  )
    .style({ width: 52, height: 64, borderRadius: 14, bgColor: colors.card, flexDirection: "column", gap: 2,
             $active: { bgColor: colors.accent, duration: 150 } })
    .class({ active: () => selectedDay.value === i })
    .onClick(() => { selectedDay.value = i })

const HabitRow = (h: Habit) =>
  UIButton(
    UIColumn(UIText(h.done ? "✓" : "").style({ fontSize: 15, fontWeight: 700, color: "#ffffff" })).style({
      width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
      bgColor: h.done ? h.color : "transparent", border: h.done ? "0" : `2px solid ${h.color}`,
    }),
    UIColumn(UIText(h.title).style(type.body), UIText(h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
  )
    .style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
    .onClick(() => toggle(h))

const screen = UIScreen(
  UIRow(
    UIText("Today").style({ ...type.h1, flexGrow: 1 }),
    UIText(() => `${doneCount.value} / ${habits.value.length}`).style({ ...type.small, fontSize: 15 }),
  ).style({ px: 20, alignItems: "flex-end" }),

  UIScrollable(days.map(DayCard))
    .style({ scrollDirection: "horizontal", showScrollbar: false, gap: 8, px: 20, py: 12, flexShrink: 0 }),

  UIScrollable(() => habits.value.map(HabitRow))
    .style({ flexGrow: 1, gap: 10, px: 20, pb: 12, showScrollbar: false }),

  UIButton(UIText("+ New habit").style({ ...type.body, fontWeight: 700, color: "#ffffff" }))
    .style({ bgColor: colors.accent, borderRadius: 16, p: 16, mx: 20, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })

screen.open()

The horizontal padding moved from the screen onto the elements (px: 20, mx: 20) — for the sake of the strip, which needs to slide under the edge. The button has no handler again for now: in the next steps it will open a separate screen with a form — and for that we need navigation.

What you learned

  • The screen doesn't scroll: chrome + one UIScrollable body with flexGrow: 1; it's the only element that shrinks on its own (flexShrink: 1).
  • A horizontal strip is the same scroll with scrollDirection: "horizontal"; padding goes on it, flexShrink: 0 protects its height, snap makes a pager.
  • UIVirtualizedList is for long and growing data: a stable keyOf, a pure render, estimatedHeight, a mandatory size; data via setData / append / update; paging via onEndReached(threshold, cb).
  • Programmatic scrolling exists only on the virtualized list.

Reference: Containers · Virtualized lists · Screens & Router · Dates.

Next: screens & navigation — "Today" and "History" tabs, a habit screen on top, the back button.