Forms & keyboard
Step 6 of the UI track. The "+ New habit" button finally does what it promises: it opens a screen with a form — name, time, color, note. Along the way — inputs and their styles, the focus chain on the "next" key, validation that doesn't annoy, and the little you need to know about the keyboard (spoiler: almost nothing — the host takes care of it).
The input
UIInput() is a single-line field, UITextArea() a multi-line one. The text lives in the .value property (not in a style), every edit arrives in .onChange(v => …). Like a button, a field draws nothing of its own — background, border, radius and height are yours to set; without an explicit height it collapses to the text height, without width or flexGrow to its placeholder's width. The focus ring is the system class $focused:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const name = signal("")
const nameInput = UIInput()
.style({
height: 52, px: 14, borderRadius: 12, fontSize: 16,
bgColor: colors.card, border: `1px solid ${colors.line}`,
placeholder: "e.g. “Morning run”", placeholderColor: colors.muted,
$focused: { borderColor: colors.accent },
})
.onChange((v) => { name.value = v })
const screen = UIScreen(
UIText("New habit").style({ fontSize: 28, fontWeight: 800 }),
UIText("Name").style({ color: colors.muted, fontSize: 13, mt: 16 }),
nameInput,
UIText("This is how the card will look").style({ color: colors.muted, fontSize: 13, mt: 20 }),
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: colors.accent }),
UIText(() => name.value.trim() || "Habit name")
.style({ fontSize: 16, fontWeight: 600, flexGrow: 1, opacity: () => (name.value.trim() ? 1 : 0.4) }),
UIText("21:00").style({ color: colors.muted, fontSize: 13 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 6 })
screen.open()Tap the field and start typing — the preview card updates with every letter. Note that the field isn't "controlled" the React way — it stores its own text, and onChange merely reports it. Setting a value from code is nameInput.value = "…", and it works before the screen opens: that's how you fill an edit form.
typeis a keyboard hint, not a validator:"email","phone","number","decimal","url","search","password"."number"shows the number pad but won't block pasted letters — check inonChange.type: "date"and"time"aren't keyboards but native pickers: the value is always canonical ("2026-08-19","21:00") while the display is localized.maxLength,autocapitalize,autocorrectare styles too.placeholderColorcolors the placeholder,colorthe typed text.- A
UITextAreawithout an explicitheightgrows with its text; clamp it withminHeight/maxHeight— beyond that the text scrolls inside.
Keyboard and the field chain
The keyboard is almost entirely the host's job: it shifts the layout so the field stays visible, scrolls the nearest scroll container to it, and dismisses the keyboard by universal rules. A tap on a button or another field does not hide the keyboard, scrolling doesn't either, and a tap on a "dead" area (background, text) does. An app usually writes no keyboard code at all — except one thing: the "next" key.
enterKey: "next" labels the return key, and .onSubmit(() => next.focus()) moves the focus — so the user fills the form without leaving the keyboard. The last field gets enterKey: "done" and its onSubmit submits the form:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const box: Style<UIInput> = {
px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card, border: `1px solid ${colors.line}`,
placeholderColor: colors.muted, $focused: { borderColor: colors.accent },
}
const Field = (label: string, input: UIInput | UITextArea) =>
UIColumn(UIText(label).style({ color: colors.muted, fontSize: 13 }), input).style({ gap: 6 })
const time = signal("")
const nameInput = UIInput().style({ ...box, height: 52, placeholder: "Name", enterKey: "next" })
const timeInput = UIInput().style({ ...box, height: 52, type: "time", placeholder: "Pick a time" })
const noteInput = UITextArea().style({ ...box, py: 14, minHeight: 52, maxHeight: 120, placeholder: "Note (optional)" })
nameInput.onSubmit(() => timeInput.focus()) // "next" → the next field
timeInput.onChange((v) => { time.value = v }) // "HH:MM" — always canonical
const screen = UIScreen(
UIText("New habit").style({ fontSize: 28, fontWeight: 800, mb: 8 }),
Field("Name", nameInput),
Field("Time", timeInput),
Field("Note", noteInput),
UIText(() => (time.value ? `We'll remind you at ${time.value}` : "No time picked"))
.style({ color: colors.muted, fontSize: 15, mt: 8 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 14 })
screen.open()- While the keyboard is up, the layout shrinks to the area above it (
keyboardShrink: true, the default of the focused field). That's why a long form is aUIScrollablebody, not a column: the host scrolls to the field, and the "Save" button stays reachable.keyboardShrink: falseis the overlay mode for chats, which compute their own inset fromapp.keyboardHeight. - In a
UITextAreathe return key is a newline; it has noonSubmit, so it goes last or drops out of the chain. enterKeytakes"done" | "go" | "next" | "search" | "send". After"next"the keyboard stays — you move the focus; after the others it hides itself.input.focus()/input.blur()— programmatic (e.g. autofocus in a search screen'sonOpen); before mountfocus()is a no-op.- The note grows with its text because it has no
height— onlyminHeightandmaxHeight;py: 14instead of a fixed height keeps the text in place.
Validation
A good form "rewards early, punishes late": while the user is typing it stays quiet (and if an error is already shown, typing clears it); on leaving a field it judges only a non-empty value; on "Save" it checks everything and focuses the first problem field — the host scrolls to it by itself. An error is an ordinary signal, and the line under the field is reserved with minHeight so the form doesn't jump:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
const Field = (label: string, o: { placeholder: string; check: (v: string) => string | null }) => {
const error = signal<string | null>(null)
const input = UIInput()
.style({
height: 52, px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card, placeholder: o.placeholder,
placeholderColor: colors.muted, $focused: { borderColor: colors.accent },
border: () => `1px solid ${error.value ? colors.danger : colors.line}`,
})
.onChange(() => { error.value = null }) // typing means fixing
.onBlur(() => { if (input.value.trim()) error.value = o.check(input.value.trim()) }) // on leaving — judge non-empty
const validate = () => { error.value = o.check(input.value.trim()); return error.value === null }
const node = UIColumn(
UIText(label).style({ color: colors.muted, fontSize: 13 }),
input,
UIText(() => error.value ?? "").style({ color: colors.danger, fontSize: 13, minHeight: 18 }), // the line is reserved
).style({ gap: 6 })
return { node, input, validate }
}
const name = Field("Name", {
placeholder: "e.g. “Stretching”",
check: (v) => (v.length === 0 ? "Give it a name" : v.length < 3 ? "Too short — at least 3 characters" : null),
})
const goal = Field("Weekly goal", {
placeholder: "1 to 7",
check: (v) => (/^[1-7]$/.test(v) ? null : "A number from 1 to 7"),
})
goal.input.style({ type: "number" })
const saved = signal("")
const submit = () => {
const fields = [name, goal]
const firstBad = fields.filter((f) => !f.validate())[0] // check everything, show every error
if (firstBad) { firstBad.input.focus(); return } // and focus the first one
saved.value = `Saved: “${name.input.value.trim()}”, ${goal.input.value}×/week`
}
const screen = UIScreen(
UIText("New habit").style({ fontSize: 28, fontWeight: 800, mb: 8 }),
name.node,
goal.node,
UIButton(UIText("Save").style({ fontSize: 16, fontWeight: 700 }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
.onClick(submit),
UIText(() => saved.value).style({ color: "#34c759", fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 8 })
screen.open()Tap "Save" on the empty form — both errors appear at once and the focus lands in "Name". Start typing — the error disappears. The button is never disabled: a tap on an incomplete form explains what's missing, a dead grey button doesn't. While a request is in flight, show the busy state on the button itself and swallow repeat taps.
Note Field — it returns not an element but an object { node, input, validate }: the node for layout, the input for focusing, and the check function for submit. That's how a field kit is built in a real project: a small form controller takes an array of such objects and assembles both the enterKey: "next" chain and "check everything, focus the first invalid" from it.
The form screen in the app
Putting it together: the habit list, the button opens a "New habit" screen on top (Router.push), the form has a name, a time and color chips, "Save" validates, adds the habit to the signal and goes back:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
const type = { 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 }
const habits = signal<Habit[]>([
{ id: 1, title: "Morning run", time: "07:30", color: "#34c759" },
{ id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a" },
])
const PALETTE = ["#FF4032", "#34c759", "#ff9f0a", "#5e9eff", "#af6bff", "#2fd6c8"]
const NewHabitScreen = () => {
const error = signal<string | null>(null)
const color = signal(PALETTE[0])
const box: Style<UIInput> = { height: 52, px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card,
placeholderColor: colors.muted, $focused: { borderColor: colors.accent } }
const nameInput = UIInput()
.style({ ...box, placeholder: "e.g. “Stretching”", enterKey: "next", border: () => `1px solid ${error.value ? colors.danger : colors.line}` })
.onChange(() => { error.value = null })
.onSubmit(() => timeInput.focus())
const timeInput = UIInput().style({ ...box, type: "time", placeholder: "Time", border: `1px solid ${colors.line}` })
timeInput.value = "21:00"
const save = () => {
const title = nameInput.value.trim()
if (title.length < 3) { error.value = "The name needs at least 3 characters"; nameInput.focus(); return }
habits.value = [...habits.value, { id: Date.now(), title, time: timeInput.value || "21:00", color: color.value }]
Router.pop()
}
const ColorChip = (c: string) =>
UIButton()
.style({ width: 32, height: 32, borderRadius: 16, bgColor: c, opacity: 0.45, $active: { opacity: 1, border: "3px solid #ffffff" } })
.class({ active: () => color.value === c })
.onClick(() => { color.value = c })
return UIScreen(
UIButton(UIText("← Cancel").style({ ...type.small, fontSize: 15 })).style({ alignSelf: "flex-start", py: 6 }).onClick(() => Router.pop()),
UIText("New habit").style({ fontSize: 28, fontWeight: 800, mt: 8, mb: 8 }),
UIText("Name").style(type.small), nameInput,
UIText(() => error.value ?? "").style({ ...type.small, color: colors.danger, minHeight: 18 }),
UIText("Time").style(type.small), timeInput,
UIText("Color").style({ ...type.small, mt: 12 }),
UIRow(PALETTE.map(ColorChip)).style({ gap: 10 }),
UISpacer(),
UIButton(UIText("Save").style({ ...type.body, fontWeight: 700 }))
.style({ bgColor: () => color.value, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
.onClick(save),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 6 })
.onBackPressed(() => Router.pop())
}
const HabitRow = (h: Habit) =>
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
UIText(h.title).style({ ...type.body, flexGrow: 1 }),
UIText(h.time).style(type.small),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })
const home = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
UIButton(UIText("+ New habit").style({ ...type.body, fontWeight: 700 }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
.onClick(() => Router.push(NewHabitScreen())),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })
Router.init(home)The form screen is a factory with no arguments: each time it builds a fresh form with its own signals, so nothing needs resetting after a save — the next push creates a new one. The "Save" button takes the chosen color through the bgColor: () => color.value binding — a small but pleasant response to the choice. The note from the keyboard block was left out here purely for listing length.
What you learned
UIInput/UITextArea: text in.value, edits inonChange; a field draws and sizes nothing itself —height, background and border are yours; focus is$focused.typeis a keyboard hint, and"date"/"time"are native pickers with a canonical value.- The field chain:
enterKey: "next"+onSubmit(() => next.focus()); the last field is"done"and submits. The host does the rest of the keyboard work; a long form goes in aUIScrollable. - Validation: quiet while typing, judge non-empty on leaving, on submit check everything and focus the first invalid; reserve the error line with
minHeight. - A form screen is a factory with its own state; saving writes into the app signal and calls
Router.pop().
Reference: Buttons & inputs · Style classes · App & keyboard · Screens & Router.
Next: overlays — a delete confirmation dialog, a picker sheet and a "⋯" menu: UIModal, UIBottomSheet, UIPopover.