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

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:

TypeScript
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.

  • type is 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 in onChange.
  • 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, autocorrect are styles too. placeholderColor colors the placeholder, color the typed text.
  • A UITextArea without an explicit height grows with its text; clamp it with minHeight / 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:

TypeScript
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 a UIScrollable body, not a column: the host scrolls to the field, and the "Save" button stays reachable. keyboardShrink: false is the overlay mode for chats, which compute their own inset from app.keyboardHeight.
  • In a UITextArea the return key is a newline; it has no onSubmit, so it goes last or drops out of the chain.
  • enterKey takes "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's onOpen); before mount focus() is a no-op.
  • The note grows with its text because it has no height — only minHeight and maxHeight; py: 14 instead 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:

TypeScript
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:

TypeScript
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 in onChange; a field draws and sizes nothing itself — height, background and border are yours; focus is $focused.
  • type is 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 a UIScrollable.
  • 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.