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:
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: 1gives the scroll all the space between the chrome and the button.UIScrollableis the only container withflexShrink: 1by default, so it shrinks and scrolls instead of pushing the button off-screen. If a wrapperUIColumnappears between it and the screen, that wrapper needsflexShrink: 1by hand.- Function children work here too:
UIScrollable(() => habits.value.map(HabitRow))is the same reactive list as in the state step. No moreUISpacer: the scroll takes the remainder itself. gapand padding are properties of the scroll itself;showScrollbar: falsehides 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.
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:
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 ownpx. flexShrink: 0restores normal behavior for the strip — otherwise the vertical layout could squeeze its height.- A
UIButtonwithflexDirection: "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"andwidth: "100%"children; the current page isonScrolldivided 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:
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:
keyOfis a stable id, not an index: the list matches rows by key acrosssetData,updateandremoveByKey.renderis 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).estimatedHeightmatters only until the first measurement; a closer guess just reduces scroll jitter.- The list has no intrinsic height — without
flexGrow: 1(or aheight) 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 thingsUIScrollablelacks.
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:
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
UIScrollablebody withflexGrow: 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: 0protects its height,snapmakes a pager. UIVirtualizedListis for long and growing data: a stablekeyOf, a purerender,estimatedHeight, a mandatory size; data viasetData/append/update; paging viaonEndReached(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.