Polish
Step 8, the last of the UI track. The tracker works: screens, tabs, a form, overlays. What's left is what separates "works" from "feels good": the list's entrance, a card's response to a tap, a short vibration, padding that respects the notch and the gesture bar, and a screen that doesn't look empty when everything is done. None of it needs new concepts — just attention to detail and three or four techniques.
Entrance
el.animateFrom({ … }) is the entry animation: the element snaps to the given values and eases back to its own style. The element's style is untouched — it's an effect, not a state. The natural place to run it is the screen's onOpen, and a delay by index gives a "staircase":
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const TITLES = ["Morning run", "Read 20 pages", "Practice piano", "Stretching"]
const COLORS = ["#34c759", "#ff9f0a", "#5e9eff", "#af6bff"]
const rows = TITLES.map((title, i) =>
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: COLORS[i] }),
UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" }),
)
const enter = () => {
rows.forEach((row, i) =>
row.animateFrom({ opacity: 0, transform: "translateY(24px)", duration: 350, delay: i * 70 }),
)
}
const screen = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(rows).style({ gap: 10, mt: 12 }),
UIButton(UIText("Play it again").style({ color: colors.muted, fontSize: 15 }))
.style({ p: 12, mt: 8, $pressed: { opacity: 0.6 } })
.onClick(enter),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
.onOpen(enter) // on every open, not just the first
screen.open()animateFromis the entrance,animateToa transition to new values that commits them into the style (commit: falseskips the commit — for exit animations). Durations are milliseconds.- Repeating effects use
loop:dot.animateTo({ transform: "scale(1.4)", opacity: 0.4, duration: 600, loop: true })pulses back and forth and never commits; stop it with any lateranimateToon that element. - Don't overdo it: 250–400 ms for an entrance and one effect per screen is the norm. An
onOpenanimation also plays when you come back down the stack — if that's too much, gate it to the first show with a flag. - Transitions between screens on
Router.replacecome from the theme:theme({ replaceTransition: "fade" });push/popare animated by the host.
Press feedback
The user should feel the tap. Three layers of feedback, from mandatory to delightful: $pressed (while the finger is on the element), a smooth state transition through a class with a duration, and a short vibration device.vibrate("light") (a silent no-op where there's no motor — the browser, an iPad).
This is also where the second way of storing done, promised in the state step, comes in: a signal inside the object. The card is then not recreated but switches a class — and the transition animates:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
type Habit = { title: string; color: string; done: Signal<boolean> }
const habits: Habit[] = [
{ title: "Morning run", color: "#34c759", done: signal(true) },
{ title: "Read 20 pages", color: "#ff9f0a", done: signal(false) },
{ title: "Stretching", color: "#af6bff", done: signal(false) },
]
const doneCount = computed(() => habits.filter((h) => h.done.value).length)
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(
UIText("✓").style({ fontSize: 15, fontWeight: 700, opacity: 0, $done: { opacity: 1, duration: 200 } }),
).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
border: `2px solid ${h.color}`, bgColor: "transparent",
$done: { bgColor: h.color, duration: 200 },
}),
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1, $done: { opacity: 0.55, duration: 200 } }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { transform: "scale(0.97)", duration: 120 } })
.class({ done: () => h.done.value }) // one class — three animated reactions
.onClick(() => {
h.done.value = !h.done.value
device.vibrate(h.done.value ? "light" : "selection")
})
const screen = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIText(() => `${doneCount.value} of ${habits.length}`).style({ color: colors.muted, fontSize: 15 }),
UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()$pressed: { transform: "scale(0.97)", duration: 120 }— the card dips slightly under the finger and comes back. The cascade does the same to children if they declare their own$pressedblocks.- Every element animates the class switch by its own
duration; the switch itself is one signal write. - Which to pick: an array in a signal plus a new object — when the list's structure changes (add, remove, sort); a signal inside the object — when a property of an existing row toggles often. They combine:
habitsa signal of an array,donea signal in the item. - Haptic styles are semantic —
"light" | "medium" | "heavy" | "selection" | "success" | "warning" | "error"— because that's the only vocabulary that feels equally native on iOS and Android.
Safe areas and orientation
max(safe-top, 24px) from the first step honestly works, but the SDK has ready comfort tokens: comfort-top / comfort-bottom / comfort-left / comfort-right — "where content should comfortably start". On an iPhone with a notch that's max(inset, knob); on Android with exact-height system bars it's inset + knob; the host knows which kind of bar it has, so one screen lands right everywhere. The pair forms comfort-x / comfort-y go on px / py; the horizontal tokens double as the page gutter and move content clear of the notch in landscape:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", "comfort-left": 20, "comfort-right": 20 }) // this app's gutter is 20
const Card = (title: string, text: string) =>
UIColumn(
UIText(title).style({ fontSize: 16, fontWeight: 700 }),
UIText(text).style({ color: colors.muted, fontSize: 14, lineHeight: "1.4em", lineClamp: 2 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 16, gap: 6, onLandscape: { flex: 1 } })
const screen = UIScreen(
UIRow(
UIText("Insets").style({ fontSize: 32, fontWeight: 800, flexGrow: 1 }),
UIImage(assetIcon("lucide:smartphone")).style({ width: 24, height: 24, tintColor: colors.muted }),
).style({ alignItems: "center" }),
UIColumn(
Card("comfort-top", "Clear of the notch on iPhone and of the status bar on Android; in a browser, just a margin."),
Card("comfort-x", "The page gutter; in landscape on a notched phone it also moves content clear of the notch."),
Card("comfort-bottom", "Flush over the home indicator on iOS, with a gap above Android's three-button bar."),
).style({ gap: 10, mt: 12, onLandscape: { flexDirection: "row" } }), // a row in landscape
UISpacer(),
UIButton(UIText("A button at the bottom edge").style({ fontSize: 16, fontWeight: 700 }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, px: "comfort-x", pt: "comfort-top", pb: "comfort-bottom" })
screen.open()- The raw insets —
safe-topand friends — are still there when you need the hardware fact itself (a background image under the status bar, full-screen video). For content, usecomfort-*. - The token values are tuned through the theme:
theme({ "comfort-left": 20 })— plain lengths, no formulas. onLandscape: { … }/onPortrait: { … }inside any style is a block that overlays in that orientation and lifts off when it ends. The three cards above line up in a row the moment the phone turns.lineClamp: 2cuts the description at two lines with an ellipsis — and measures the element at that height, so the cards in a row stay equal.assetIcon("lucide:smartphone")withtintColoris a monochrome registry icon;tintColorisn't for colored artwork.
The whole screen
The "Today" screen with everything at once: a staircase on open, animated toggles with haptics, comfort insets, an icon button in the header and an empty state when everything is done:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", "comfort-left": 20, "comfort-right": 20 })
type Habit = { title: string; time: string; color: string; done: Signal<boolean> }
const habits: Habit[] = [
{ title: "Morning run", time: "07:30", color: "#34c759", done: signal(true) },
{ title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: signal(true) },
{ title: "Practice piano", time: "18:00", color: "#5e9eff", done: signal(false) },
{ title: "Stretching", time: "20:30", color: "#af6bff", done: signal(false) },
]
const leftCount = computed(() => habits.filter((h) => !h.done.value).length)
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(UIText("✓").style({ fontSize: 15, fontWeight: 700, opacity: 0, $done: { opacity: 1, duration: 200 } })).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
border: `2px solid ${h.color}`, bgColor: "transparent", $done: { bgColor: h.color, duration: 200 },
}),
UIColumn(
UIText(h.title).style({ fontSize: 16, fontWeight: 600 }),
UIText(h.time).style({ color: colors.muted, fontSize: 13 }),
).style({ gap: 2, flexGrow: 1, $done: { opacity: 0.55, duration: 200 } }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { transform: "scale(0.97)", duration: 120 } })
.class({ done: () => h.done.value })
.onClick(() => { h.done.value = !h.done.value; device.vibrate(h.done.value ? "light" : "selection") })
const rows = habits.map(HabitRow)
const enter = () => rows.forEach((row, i) =>
row.animateFrom({ opacity: 0, transform: "translateY(24px)", duration: 350, delay: i * 60 }))
const allDone = UIColumn(
UIImage(assetIcon("lucide:party-popper")).style({ width: 40, height: 40, tintColor: colors.accent }),
UIText("All done!").style({ fontSize: 18, fontWeight: 700 }),
UIText("Great day. The list comes back tomorrow.").style({ color: colors.muted, fontSize: 14, textAlign: "center" }),
).style({ alignItems: "center", gap: 6, p: 24, bgColor: colors.card, borderRadius: 16, mt: 12,
display: () => (leftCount.value === 0 ? "flex" : "none") })
const screen = UIScreen(
UIRow(
UIColumn(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIText(() => (leftCount.value === 0 ? "Every habit is done" : `${leftCount.value} left`)).style({ color: colors.muted, fontSize: 15 }),
).style({ flexGrow: 1, gap: 2 }),
UIButton(UIImage(assetIcon("lucide:plus")).style({ width: 22, height: 22, tintColor: "#ffffff" }))
.style({ width: 44, height: 44, borderRadius: 22, bgColor: colors.accent, $pressed: { transform: "scale(0.92)", duration: 100 } })
.onClick(() => toast("The form from step 6 opens here")),
).style({ alignItems: "center" }),
UIScrollable(rows).style({ flexGrow: 1, gap: 10, mt: 16, showScrollbar: false }),
allDone,
).style({ bgColor: colors.bg, px: "comfort-x", pt: "comfort-top", pb: "comfort-bottom" })
.onOpen(enter)
screen.open()Check off all four habits — an "All done" card appears under the list. It always exists and is shown through a display: () => … binding — for a rare state that's simpler than rebuilding children. A round "+" button instead of a wide one — now that the list scrolls, it doesn't need to occupy the bottom edge.
What you learned
animateFrominonOpenwith adelayby index — the entrance staircase;animateToa transition,loopa pulse; all in milliseconds.- Feedback:
$pressedwithtransform, classes withdurationfor state transitions,device.vibrate("light")for touch. - A signal inside the object is the second way to hold row state; the card isn't recreated, it switches with an animation.
comfort-*tokens instead of hand-rolled safe-area formulas;onLandscapefor the rotated layout;lineClampfor even cards.- An empty state is an ordinary element with a reactive
display.
Reference: Styling — animateTo, safe areas, responsive · Style classes · device — haptics · Content — icons and lineClamp.
The UI track is done
You have a multi-screen app with state, a theme, lists, navigation, a form and overlays — and the habit of thinking in LeCodes terms: elements as objects, signals, one scroll per screen, "create once — show on demand". Where next:
- Data & networking — save the habits to
localStorage, sync with a server viafetch, show loading states. (being written) - CLI & local dev — split the project into files, run it on a phone with hot reload, run headless tests.
- LeCodes Design — design screens as live prototypes before writing the logic.
- API reference — the contract of every global you met here.