Buttons and text fields: UIButton, UIInput, UITextArea
The elements that take user input. UIButton is the only tappable container: wrap a card, a list
row or an icon in one to make it clickable. UIInput is a one-line text field, UITextArea a
multi-line one. Drags are Pointer events; the keyboard's height, to move UI
above it yourself, is app.keyboardHeight.
- make a card, a list row or an icon tappable (
UIButton) - show that a button is pressed or under the mouse
- press and hold, then drag
- read what the user types into a text field
- fill an edit form before the screen opens
- let the user pick a date or a time
- a chat message box that grows as the user types
- go to the next field on return, then submit the form
- open a screen with the keyboard already up
- keep the keyboard off the field, hide it on a tap outside
Platforms: every host; hover only with a mouse (desktop, the web, the web player), the press
ripple only on Android and desktop, no date / time picker on desktop (a plain text field there),
and the keyboard rules (keyboardShrink, keyboardDismiss) on iOS and Android, on desktop only
with its drawn keyboard — each section says its own.
At a glance
let input: UIInput
const form = UIColumn(
input = UIInput()
.style({ height: 40, px: 12, borderRadius: 8, bgColor: "white", color: "black",
placeholder: "Your name", placeholderColor: "#999" })
.onChange(v => console.log("typing:", v)),
UIButton(UIText("Submit").style({ color: "white" }))
.style({ height: 40, justifyContent: "center", bgColor: "#FF4032", borderRadius: 8,
$pressed: { opacity: 0.7 }, rippleColor: "default" })
.onClick(() => console.log("submitted:", input.value)),
).style({ gap: 8 })
UIScreen(form).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()Buttons, tappable cards and rows: UIButton
To make a card, a list row or an icon tappable, wrap it in a UIButton: onClick exists on
UIButton alone, a plain UIColumn or UIBox cannot be tapped. A button lays its children out
in a centered row (flexDirection: "row", justifyContent and alignItems "center"), so an
icon + label needs no styling; flexDirection: "column" makes a card.
const card = UIButton(
UIText("Ada Lovelace").style({ fontSize: 18, color: "white" }),
UIText("Counts things").style({ color: "#aaa" }),
).style({ flexDirection: "column", alignItems: "flex-start", gap: 4, p: 16,
bgColor: "#222", borderRadius: 12 })
.onClick(ev => console.log("card tapped at", ev.clientX, ev.clientY))
UIScreen(card).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()buttons render no chrome of their own — style them like any container (bgColor,
borderRadius, padding). Give a button an explicit height: on its own it is only as tall as
its text.
Button events: onClick, onTouchStart, onMouseEnter
What a button reports: a tap, a touch-down, a hold and a mouse cursor entering it.
UIButton(...children: (UINodeChild | UINodeChild[])[]): UIButton
button.onClick(ev => …) // the release over the button; ev: ClickEvent
button.onTouchStart(ev => …) // pointer-down; ev.track({...}) starts a drag gesture
button.onLongPress(ev => …) // finger held ~0.5 s; ev: LongPressEvent
button.isPressed: boolean // true while a finger is down on the button
button.onMouseEnter(ev => …) // a mouse cursor entered (desktop, the web; never touch)
button.isHovered: boolean // true while the cursor is over the buttonThe events carry clientX / clientY (logical px) and target (the button); the click, touch and
long-press events also pointerId. A click needs the release over the same button, with no scroll having taken the gesture — a tracked
drag that ends on the button still clicks. onTouchStart also exists on UIScreen and
UIWidget; drags (ev.track(), claim) work as described in
Pointer events. A button has the full container surface: .append /
.insert / .remove / .setContent, .style, .animateTo / .animateFrom, .onLayout.
Pressed and hover looks: $pressed, $hovered, rippleColor
Nothing changes on screen when a button is pressed or hovered unless its style asks for it, with
the reserved style classes $pressed and $hovered or a ripple:
const save = UIButton(UIText("Save").style({ color: "white", $pressed: { color: "#ffd0cc" } }))
.style({
height: 44, px: 20, bgColor: "#FF4032", borderRadius: 8,
$hovered: { bgColor: "#e8382b" }, // a mouse cursor over it
$pressed: { opacity: 0.7, duration: 150 }, // while the finger is down
rippleColor: "default", // a ripple instead, on Android and desktop
})
.onClick(() => console.log("saved"))
.named("save")
UIScreen(save).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()A $pressed block takes any style the button does (bgColor, opacity, border*, transform,
…), plus the swap's duration / delay / easing. It cascades — the icon and label inside can
declare their own $pressed blocks — and stops at a nested button, which presses on its own (a
button inside a clickable card never presses with the card). rippleColor draws a ripple on
Android and desktop and replaces the $pressed look there, children's blocks included; iOS and
the web draw no ripple and show $pressed, so setting both gives a ripple where there is one and
the $pressed look everywhere else.
$hovered cascades like $pressed and loses to it. To act on hover, onMouseEnter fires when the
cursor enters; there is no leave event — ev.track({ onMove, onEnd, onCancel }) follows the hover:
onEnd when the cursor leaves, onMove while it moves inside, onCancel when the element went
away underneath.
hover needs a mouse: desktop, the web and the web player. On iOS and Android
onMouseEnter never fires and $hovered never shows.
For a state you toggle yourself (selected, checked, active), declare your own class ($selected)
and flip it with el.class.selected = … — see Style classes. The press look wins
over a class while pressed.
Press and hold, then drag: onLongPress
onLongPress fires when the finger stays on the button past the long-press threshold (0.5 s;
Android: the system's long-press timeout) without sliding away. Like onTouchStart, the event can
ev.track({...}) the rest of the gesture, so "hold, then drag to move" is one handler:
let x = 0, y = 0
const tile = UIButton(UIText("Hold, then drag").style({ color: "white" }))
.style({ width: 180, height: 60, bgColor: "#2b3550", borderRadius: 8 })
.named("tile")
tile.onLongPress(ev => {
console.log("held")
device.vibrate("medium") // confirm the hold registered
ev.track({
claim: true,
onMove: ({ deltaX, deltaY }) => { // movement since the previous move, logical px
x += deltaX; y += deltaY
tile.style.transform = `translate(${x}px, ${y}px)`
},
onEnd: () => console.log("dropped at", x, y),
})
})
tile.onClick(() => console.log("tapped")) // a plain tap; a hold does not click
UIScreen(tile).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()A long press with a listener swallows the click that would follow the release, so onClick
and onLongPress coexist; a button without onLongPress still clicks after a long hold.
Text fields: UIInput and UITextArea
A text field the user types into: value is its text and onChange fires on every edit the user
makes. UIInput is one line, UITextArea grows to several.
UIInput(): UIInput // one-line field
UITextArea(): UITextArea // multi-line
input.value // get / set the text (a content property, not a style)
input.onChange(cb: (value: string) => void): this // every user edit; a `value` write does not fire it
input.onFocus(cb: () => void): this
input.onBlur(cb: () => void): this
input.onSubmit(cb: (value: string) => void): this // the return key (UIInput only)
input.focus() // focus from code (opens the keyboard) — once it is on screen
input.blur() // release the focus (hides the keyboard)The At a glance block is the whole form: a field, its onChange, and a button
that reads input.value.
give inputs an explicit height and a flexGrow: 1 (in a row) or width — an input
collapses to its placeholder's width and its text's height, it does not stretch like a web
<input>.
Pre-fill an edit form: value
value is writable before the screen opens — that's how you build a settings screen or an
edit form: fill the fields while you compose the tree, then open it.
const user = { name: "Ada", bio: "Counts things." }
const save = (name: string, bio: string) => console.log("save", name, bio)
const name = UIInput().style({ placeholder: "Name" })
const bio = UITextArea().style({ maxHeight: 120 })
name.value = user.name // set before open — the field comes up filled
bio.value = user.bio // a textarea comes up already grown to fit
UIScreen(UIColumn(name, bio, UIButton(UIText("Save")).onClick(() => save(name.value, bio.value)))).open()Reading value gives the field's text at any time — what the user typed or what your code set
last, whether the screen is open or not. A closed or popped screen is detached, not rebuilt: open
or push the same screen object again and its fields come back as the user left them.
Keyboard type, password and length: input styles
The keyboard a field asks for, a password field, a length cap and the focused look are styles of
the field, on top of the usual element and text styles (fontSize, color, fontFamily, …):
const field = { height: 44, px: 12, borderRadius: 8, bgColor: "white", color: "black",
placeholderColor: "#999", borderWidth: 1, borderColor: "#ccc" }
const email = UIInput().style({
...field,
placeholder: "Email",
type: "email", // "text" (default) | "password" | "search" | "phone" | "email"
// | "number" | "decimal" | "url" | "date" | "time"
enterKey: "next", // the return key's label: "done" | "go" | "next" | "search" | "send" (UIInput only)
maxLength: 64, // hard cap while typing / pasting
autocapitalize: "none", // "none" | "words" | "sentences" | "characters"; omitted = platform default
autocorrect: false, // autocorrection and suggestions; omitted = platform default
$focused: { borderColor: "#FF4032", duration: 150 }, // while focused (reserved class)
})
const password = UIInput().style({ ...field, placeholder: "Password", type: "password" })
UIScreen(UIColumn(email, password).style({ gap: 8 })).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()type values are keyboard hints, not validators — number shows the number
pad but does not block pasted letters; validate in onChange yourself. The phone-pad value is
phone (LeCodes has no HTML-style tel).
iOS, Android and the web apply all of these. Desktop masks password and applies
neither maxLength nor autocapitalize / autocorrect; there type and enterKey shape its
drawn keyboard (app.json desktop.virtualKeyboard), and enterKey: "next" keeps the focus on
submit as everywhere. The web player passes only type to the browser's field. Headless tests
set the text as given.
Pick a date or a time: date & time pickers (type: "date" | "time")
Let the user pick a date or a time with type: "date" or "time": on iOS focusing the field
opens date or time wheels in the keyboard's place, on Android a date or time dialog, on the web the
browser's own date / time field and its picker. The field itself stays a normal, fully styleable
input.
const shown = signal("No date yet")
const isDate = (v: string) => /^\d{4}-\d{2}-\d{2}$/.test(v) // desktop: the field is free text
const birthday = UIInput()
.style({ type: "date", placeholder: "Date of birth", height: 44, px: 12,
bgColor: "white", color: "black", borderRadius: 8 })
.onChange(v => { shown.value = isDate(v) ? `Born ${v}` : "Type the date as YYYY-MM-DD" })
.named("birthday")
birthday.value = "1990-04-27" // canonical in; the field shows it in the device's format
UIScreen(UIColumn(birthday, UIText(() => shown.value).style({ color: "white" })).style({ gap: 8 }))
.style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()valueis canonical:"YYYY-MM-DD"fordate,"HH:MM"(24-hour) fortime— that's whatonChangereceives and what you set from code. What the user sees in the field is a string the host formats in the device's language; never parse the display.- On iOS and Android the field takes no typing: the value comes from the picker or from code.
- An empty value shows the
placeholder. On iOS, confirming with the Done bar adopts the wheels' current position even if the user didn't spin them (open → Done = today); tapping dead space just dismisses without adopting. On Android, cancelling the dialog adopts nothing, and the time dialog is 24-hour when the system setting is. $focusedstyling andfocus()/blur()work as on any field; on iOS the wheels take the keyboard's place, sokeyboardShrinkapplies to them too.
desktop (Windows, macOS, Linux) has no picker: a date / time field is a plain text
field there, the user types into it and value is whatever was typed — check its form before
you use it, as isDate does above. Headless tests also set the text as given.
A message box that grows as you type: UITextArea
A chat composer or a comment box is a UITextArea with no explicit height: it measures its
own content and grows line by line as the user types. Clamp the range with minHeight /
maxHeight — past maxHeight the text scrolls inside the field. A fixed height opts out
(internal scrolling from the start).
const composer = UITextArea().style({
placeholder: "Message", fontSize: 16, color: "white", placeholderColor: "#888",
maxHeight: 108, // ~5 lines, then it scrolls inside
keyboardDismiss: false, // chat: tapping the transcript doesn't hide the keyboard
// no height — grows with the content; width comes from the wrapper's stretch
}).named("composer")
const send = UIButton(UIText("Send").style({ color: "white" }))
.style({ height: 44, px: 16, bgColor: "#FF4032", borderRadius: 22 })
.onClick(() => { console.log("send:", composer.value); composer.value = "" })
const bar = UIRow(
UIColumn(composer).style({ flex: 1, px: 14, py: 11, borderRadius: 22, bgColor: "#222" }), // the width the button leaves
send,
).style({ alignItems: "flex-end", gap: 10, p: 12 }) // the send button stays at the bottom while it grows
UIScreen(UIColumn().style({ flex: 1 }), bar).style({ bgColor: "#111", pt: "safe-top" }).open()Don't give the textarea itself flexGrow / flex — a flex basis overrides the intrinsic
content height and the field stops growing.
on the web the growth rides the browser's CSS field-sizing: content; a browser
without it keeps the field at one height.
Next field on return, then submit: enterKey + onSubmit
onSubmit fires when the user presses the keyboard's return key (UIInput only — in a
UITextArea Enter is a newline). On submit the keyboard goes, unless enterKey: "next" — then
you move the focus yourself and the keyboard stays up:
const style = { height: 44, px: 12, bgColor: "white", color: "black", borderRadius: 8,
borderWidth: 2, borderColor: "white", $focused: { borderColor: "#FF4032" } }
const name = UIInput().style({ ...style, placeholder: "Name", enterKey: "next" })
.onSubmit(() => email.focus()) // "Next": the keyboard stays, the focus moves on
const email = UIInput().style({ ...style, placeholder: "Email", type: "email", enterKey: "done" })
.onSubmit(() => console.log("sign up:", name.value, email.value)) // "Done": the keyboard goes
.named("email")
UIScreen(UIColumn(name, email).style({ gap: 8 })).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()focus() also covers focusing the invalid field after a failed check.
Open a screen with the keyboard up: autofocus
A screen made for typing — a composer, a chat, a search, a one-field form — opens with its field
focused and the keyboard up when the field's style has autofocus: true:
const query = UIInput()
.style({ placeholder: "Search", type: "search", enterKey: "search", height: 44, px: 12,
bgColor: "white", color: "black", borderRadius: 8, autofocus: true })
.onFocus(() => console.log("keyboard up"))
.onSubmit(q => console.log("search:", q))
UIScreen(query).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()autofocus is the SDK's behaviour over focus(), on every host: when the field's screen (or
pager page) opens, it takes the focus, once; a screen shown again does not take the keyboard back,
and the first field of a long form should not ask for it. One per screen — a second is reported in
the console and ignored.
Keep the field above the keyboard: keyboardShrink
When the on-screen keyboard opens, the screen that holds the focused field lays out again in the
space above it, and a field inside a scrollable is scrolled into view — no code needed. What the
layout does is decided by the focused field: keyboardShrink: true (default) shrinks the
layout to the area above the keyboard (one relayout); false lets the keyboard cover the UI with
no relayout — for a chat composer that moves itself, or a full-screen canvas where a reflow is
worse than overlap. Then move things yourself from the keyboard's height, app.keyboardHeight and
the "keyboard" event (app.md):
const input = UITextArea().style({ placeholder: "Message", fontSize: 16, color: "white",
keyboardShrink: false }) // the keyboard covers the screen
const composerRow = UIRow(UIColumn(input).style({ flex: 1 }))
.style({ px: 12, pt: 8, pb: 24, bgColor: "#222" })
app.addEventListener("keyboard", (height) => {
composerRow.style.pb = Math.max(24, height) // pad the row above it; 24 ≈ the safe-area bottom
})
UIScreen(UIColumn().style({ flex: 1 }), composerRow).style({ bgColor: "#111" }).open()iOS and Android. Desktop shrinks only with its drawn keyboard (app.json
desktop.virtualKeyboard); the web leaves the keyboard to the browser and ignores
keyboardShrink; the web player and headless tests always lay the focused screen out above the
keyboard, under keyboardShrink: false too.
Hide the keyboard: tap outside, scroll, return key (keyboardDismiss)
The keyboard hides on a tap on the background, a text or an image, on the return key and on
input.blur(); a tap on a button or another field never hides it, and scrolling hides it only in a
scrollable that opts in. An
app normally ships no keyboard code beyond enterKey: "next" → next.focus() — plus
keyboardDismiss: false on a chat composer.
const search = UIInput().style({ placeholder: "Search", type: "search", height: 44, px: 12,
bgColor: "white", color: "black", borderRadius: 8 })
.onBlur(() => console.log("keyboard hidden"))
const row = (fruit: string) => UIButton(UIText(fruit).style({ color: "white" }))
.style({ height: 48, justifyContent: "flex-start" })
.onClick(() => console.log("picked", fruit)) // the keyboard stays up
const results = UIScrollable(row("Apples"), row("Bananas"), row("Cherries"))
.style({ flex: 1, keyboardDismissMode: "scroll" }) // a drag in the list hides it
UIScreen(search, results).style({ bgColor: "#111", p: 24, pt: "safe-top", gap: 8 }).open()- A tap on a control never hides it. Tapping a
UIButtonfires it with the keyboard still up; tapping another field just moves the focus. (A send button next to a composer works without the keyboard blinking — callinput.blur()from a handler when you do want it gone.) - Scrolling never hides it — unless the scrollable opts in. So an autocomplete list under a
focused field can be scrolled and its options tapped with the keyboard up. On iOS a downward
drag that reaches the keyboard itself hides it interactively (the iMessage gesture). A
scrollable changes its own policy with the
keyboardDismissModestyle (containers.md):"scroll"hides it the moment any drag in it starts (a search-results list),"none"turns even the interactive drag off. - A tap on a non-interactive area hides it — background, text, images. This is the one part a
field can opt out of with
keyboardDismiss: false(chat composers — then taps never hide it, and onlyblur(), the return key and the iOS drag-down remain). - The return key hides it per
enterKey(except"next"), and iOS shows a Done bar on the pads without a return key (number/decimal/phone, and the date / time wheels).
iOS and Android. Desktop follows the tap rules only with its drawn keyboard — with a
physical keyboard a click anywhere outside the field, a button too, takes the focus from it —
and never reads keyboardDismissMode. The web leaves the keyboard and the focus to the browser
and ignores keyboardDismiss and keyboardDismissMode. The web player and headless tests
follow the tap rules but ignore keyboardDismiss and keyboardDismissMode.
Pitfalls
// ✗ touch handler on a plain container
UIColumn(...).onTouchStart(cb) // no such method — only UIButton/UIScreen/UIWidget
// ✓ wrap in a UIButton
UIButton(...).onTouchStart(cb)
// ✗ button next to a 40px input shrinks to its text height
UIRow(input.style({ height: 40 }), UIButton(icon))
// ✓ give controls the same explicit height
UIRow(input.style({ height: 40 }), UIButton(icon).style({ height: 40 }))See also
- Pointer events & gestures — event objects,
ev.track(),claim - Keyboard height —
app.keyboardHeightand thekeyboardevent - Styling —
.style(), the property vocabulary,animateTo - Style classes —
$pressed/$hovered/$focused, the cascade, precedence - Containers —
UIRow/UIColumnlayout rules a button inherits