Input
A single-line, controlled text input widget with cursor navigation and editing support.
A single-line, controlled text input widget with cursor navigation and editing support.
Usage
ui.input({
id: "name",
value: state.name,
onInput: (value) => app.update((s) => ({ ...s, name: value })),
})Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique identifier for focus and event routing |
value | string | required | Current input value (controlled) |
accessibleLabel | string | - | Optional semantic label for focus announcements and debugging |
disabled | boolean | false | Disable editing and dim appearance |
readOnly | boolean | false | Keep the input focusable/selectable while preventing edits |
focusable | boolean | true | Opt out of Tab focus order while keeping id-based routing available |
style | TextStyle | - | Custom styling (merged with focus/disabled state) |
onInput | (value: string, cursor: number) => void | - | Callback when value changes |
onBlur | () => void | - | Callback when input loses focus |
focusConfig | FocusConfig | - | Control focus visuals; { indicator: "none" } suppresses focused input decoration |
key | string | - | Reconciliation key for dynamic lists |
Design System Styling
Inputs are design-system styled by default. The input renderer applies
inputRecipe() output automatically from the active ThemeDefinition.
| Prop | Type | Default | Description |
|---|---|---|---|
dsSize | "sm" | "md" | "lg" | "md" | Size preset (controls padding and height) |
placeholder | string | - | Placeholder text shown when value is empty |
Manual style overrides are merged on top of recipe output via
mergeTextStyle(baseStyle, ownStyle) (they do not disable recipes).
Framed chrome requires both width >= 3 and height >= 3. At height = 1,
the recipe still applies text/background styling, but no box border is drawn.
Behavior
Enabled inputs are focusable by default. readOnly keeps the input focusable/selectable while blocking edits, and focusable: false removes it from Tab traversal. Clicking the input focuses it when it remains focusable. When focused:
- Text entry inserts at cursor position
- Left/Right move by grapheme cluster
- Ctrl+Left/Ctrl+Right move by word boundaries
- Home/End move to start/end of input
- Shift+Left/Shift+Right extends selection by grapheme
- Shift+Home/Shift+End extends selection to start/end
- Shift+Ctrl+Left/Shift+Ctrl+Right extends selection by word
- Ctrl+A selects all text
- Ctrl+C copies active selection to system clipboard (OSC 52)
- Ctrl+X cuts active selection to system clipboard (OSC 52)
- Ctrl+Z undoes the last edit
- Ctrl+Shift+Z or Ctrl+Y redoes the last undone edit
- Backspace/Delete remove one grapheme cluster when no selection is active
- Backspace/Delete delete the selected range when selection is active
- Typing with an active selection replaces the selected range
- Paste strips
\r/\n(single-line input) and keeps tabs - Tab moves focus to next widget
Inputs are always controlled - the value prop determines what is displayed, and onInput is how edits persist across frames.
For multi-line text, use ui.textarea.
Input Editor State
Input editing is grapheme-aware and internally tracks:
cursor: current caret offset at a grapheme boundaryselectionStart: anchor offset, ornullwhen no selection is activeselectionEnd: active/caret offset, ornullwhen no selection is active
Renderer integrations receive these through the runtime input editor result to support selection highlighting.
Examples
Controlled input
type State = { email: string };
app.view((state) =>
ui.input({
id: "email",
value: state.email,
onInput: (value) => app.update((s) => ({ ...s, email: value })),
})
);With useForm binding
ui.input({ id: "email", ...form.bind("email") });Validation on blur
Use onBlur to trigger validation when the user leaves the field:
ui.input({
id: "email",
value: state.email,
onInput: (value) => app.update((s) => ({ ...s, email: value })),
onBlur: () => validateEmail(state.email),
})With a field wrapper
Combine with field for labels and error display:
ui.field({
label: "Email",
required: true,
error: state.errors.email,
children: ui.input({
id: "email",
value: state.email,
onInput: (v) => app.update((s) => ({ ...s, email: v })),
}),
})Unicode Handling
Text editing is based on grapheme clusters using a pinned Unicode version. This ensures:
- Emoji and combined characters are handled as single units
- Cursor movement is consistent across platforms
- Deterministic behavior for any input string