Guide
Forms and the keyboard
Text entry is the <Input> element: a UITextField or UITextView on iOS, an EditText on Android. Typing, the caret, selection, autocorrection and autofill are the platform’s own; your app gets the text through events and can control it. nsh also keeps focused inputs above the software keyboard on its own, and gives you the keyboard’s height as a reactive value for the cases it cannot handle by itself.
import { Input, mount, Scroll, signal, Text, View, type YNode } from "nsh";
const field = {
fontSize: 16,
color: "#1f2328",
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#d0d7de",
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
} as const;
const label = { fontSize: 13, color: "#57606a" } as const;
function App() {
const name = signal("");
const email = signal("");
const code = signal("");
const notes = signal("");
const notesHeight = signal(0);
const focused = signal("nothing");
const sent = signal("");
let emailInput!: YNode;
let passwordInput!: YNode;
let notesInput!: YNode;
let tries = 0;
const submit = () => sent.set(`#${++tries}: signed up ${name() || "nobody"} <${email() || "no e-mail"}>`);
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled">
<View style={{ padding: 16, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700" }}>Sign up</Text>
<Text style={label}>Focused: {focused()}</Text>
<Text style={label}>Name (controlled, return goes to the next field)</Text>
<Input
style={field}
value={name()}
onChangeText={(t) => name.set(t)}
placeholder="Ada Lovelace"
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => emailInput.focus()}
onFocus={() => focused.set("name")}
/>
<Text style={label}>E-mail</Text>
<Input
ref={emailInput}
style={field}
value={email()}
onChangeText={(t) => email.set(t.trim().toLowerCase())}
placeholder="ada@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordInput.focus()}
onFocus={() => focused.set("e-mail")}
/>
<Text style={label}>Password (uncontrolled)</Text>
<Input
ref={passwordInput}
style={field}
secureTextEntry
autoComplete="new-password"
placeholder="At least 8 characters"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => notesInput.focus()}
onFocus={() => focused.set("password")}
/>
<Text style={label}>Code (number pad, 6 digits)</Text>
<Input
style={{ ...field, letterSpacing: 8, textAlign: "center", fontSize: 22 }}
value={code()}
onChangeText={(t) => code.set(t.replace(/[^0-9]/g, ""))}
keyboardType="number-pad"
autoComplete="one-time-code"
maxLength={6}
onFocus={() => focused.set("code")}
/>
<Text style={label}>About you (grows with its text, 200 max)</Text>
<Input
ref={notesInput}
multiline
maxLength={200}
value={notes()}
onChangeText={(t) => notes.set(t)}
onContentSizeChange={(e) => notesHeight.set(Math.min(e.height, 120))}
placeholder="Return adds a line"
style={{ ...field, height: Math.max(notesHeight(), 22) + 20 }}
onFocus={() => focused.set("notes")}
onBlur={() => focused.set("nothing")}
/>
<Text style={{ ...label, textAlign: "right" }}>{`${notes().length}/200`}</Text>
<View
onPress={submit}
activeOpacity={0.6}
style={{ padding: 14, borderRadius: 10, backgroundColor: "#0969da", alignItems: "center" }}
>
<Text style={{ color: "#ffffff", fontWeight: "600" }}>Sign up</Text>
</View>
<Text style={{ color: "#1a7f37" }}>{sent()}</Text>
</View>
</Scroll>
);
}
mount(App);For a labeled field with a clear button in the mobile UI set’s look, see TextField. This page covers the underlying element.
#Controlled and uncontrolled inputs
An input is controlled when you pass value: it shows what your signal holds, and you update the signal from onChangeText.
import { Input, mount, signal, Text, View } from "nsh";
function App() {
const name = signal("");
return (
<View style={{ flex: 1, padding: 16, paddingTop: 60, gap: 8 }}>
<Input
value={name()}
onChangeText={(text) => name.set(text)}
placeholder="Your name"
style={{ fontSize: 17, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 10, padding: 12 }}
/>
<Text>Hello, {name() || "stranger"}</Text>
</View>
);
}
mount(App);
Because you control the value, you can transform what the user types: onChangeText={(t) => code.set(t.replace(/[^0-9]/g, ""))} keeps digits only, onChangeText={(t) => tag.set(t.toUpperCase())} uppercases.
An input is uncontrolled without value: it keeps its own text, starting at defaultValue, and you read it from onChangeText or onSubmitEditing when you need it. Use this for fields you only read on submit, such as a password.
#Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | The text, controlled by JS. | |
defaultValue | string | The initial text of an uncontrolled input. | |
placeholder | string | Shown while the input is empty. | |
placeholderTextColor | ColorValue | The placeholder’s color (also placeholder:text-* classes). | |
secureTextEntry | boolean | false | Hides the text (passwords). |
keyboardType | KeyboardType | "default" | The keyboard layout. |
returnKeyType | ReturnKeyType | "default" | The return key’s label. |
autoCapitalize | "none" | "sentences" | "words" | "characters" | "sentences" | Automatic capitalization. |
autoCorrect | boolean | true | The system’s corrections. |
autoComplete | AutoComplete | What the input holds, for autofill. | |
multiline | boolean | false | Several lines; return inserts a line break. |
maxLength | number | The most characters the user can enter. | |
editable | boolean | true | The user can change the text. |
autoFocus | boolean | false | Focused when it appears. |
blurOnSubmit | boolean | true | Return unfocuses a single-line input. |
caretHidden | boolean | false | No caret is drawn. |
selectionColor | ColorValue | The caret and the selection. | |
selection | { start: number; end?: number } | The controlled selection, in UTF-16 units. | |
allowFontScaling | boolean | true | Follow the system text size. |
maxFontSizeMultiplier | number | The upper bound of the system scale (0: none). | |
ios={{ inputAccessoryViewID }} | string | iOS: the bar shown over the keyboard (see below). | |
ios={{ textContentType }} | TextContentType | iOS’s names for autoComplete values. |
Events:
| Event | Payload | When |
|---|---|---|
onChangeText | text: string | The text changed. |
onSubmitEditing | { text } | The return key was pressed (single line). |
onFocus / onBlur | The input gained / lost focus. | |
onSelectionChange | { start, end } | The caret or selection moved (UTF-16 units). |
onKeyPress | { key } | Before the text changes: key is the typed text, "Backspace" or "Enter". |
onContentSizeChange | { width, height } | The text’s size changed (at the input’s width when multiline). |
An input is styled like text: fontSize, color, fontFamily, textAlign, letterSpacing and the rest of text styles, plus borders, padding and backgrounds. The focus: Tailwind variant applies while the input has focus:
<Input class="rounded-lg border border-zinc-300 px-3 py-2 text-base focus:border-blue-500" placeholder="Search" />
#Keyboards and autofill
keyboardType picks the layout:
| Value | Keyboard |
|---|---|
"default" | Text. |
"email-address" | Text with @ and . at hand. |
"url" | Text with / and .com. |
"number-pad" | Digits only. |
"numeric" | Digits and, on some keyboards, signs. |
"decimal-pad" | Digits and a decimal separator. |
"phone-pad" | A phone dial pad. |
autoComplete tells the system what the field holds, so it can offer saved names, addresses, passwords and one-time codes from messages. It takes these values: "name", "given-name", "family-name", "email", "tel", "username", "current-password", "new-password", "one-time-code", "street-address", "postal-code", "country", "cc-number", "cc-exp", "cc-csc", "url", "birthdate-full" and others, or "off". iOS’s own names ("emailAddress", "oneTimeCode", …) are accepted too, in autoComplete or ios={{ textContentType }}.
<Input keyboardType="email-address" autoCapitalize="none" autoCorrect={false} autoComplete="email" placeholder="E-mail" />
<Input secureTextEntry autoComplete="new-password" placeholder="Password" />
<Input keyboardType="number-pad" autoComplete="one-time-code" maxLength={6} placeholder="Code" />
#The return key and moving between fields
returnKeyType labels the return key: "default", "done", "go", "next", "search" or "send". Pressing it fires onSubmitEditing on a single-line input, which then loses focus unless blurOnSubmit={false}.
To move to the next field, keep refs to the inputs and call focus():
import { Input, mount, signal, View, type YNode } from "nsh";
function SignIn() {
const email = signal("");
let password!: YNode;
const submit = () => console.log("sign in", email());
return (
<View style={{ flex: 1, padding: 16, paddingTop: 60, gap: 12 }}>
<Input
value={email()}
onChangeText={(t) => email.set(t)}
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => password.focus()}
placeholder="E-mail"
/>
<Input ref={password} secureTextEntry returnKeyType="go" onSubmitEditing={submit} placeholder="Password" />
</View>
);
}
mount(SignIn);
blurOnSubmit={false} on the first field keeps the keyboard up while focus moves, so it does not drop and rise again. A chat input that sends on return and stays focused uses the same prop.
Both maxLength and blurOnSubmit are enforced natively: the platform asks “may this character be added?” and “should return end editing?” as the user types, and nsh answers from the props without waiting for JS.
#Focus from code
An input’s ref has:
| Method | Description |
|---|---|
focus() | Focuses the input and shows the keyboard. |
blur() | Unfocuses it. |
setSelection(start, end?) | Selects start..end (UTF-16 units); a caret without end. |
Keyboard.dismiss() from nsh/ui/mobile unfocuses whichever input has focus:
import { View } from "nsh";
import { Keyboard } from "nsh/ui/mobile";
<View onPress={() => Keyboard.dismiss()}>…</View>
#Selection
onSelectionChange reports the caret and selection; selection sets them. Positions are UTF-16 code units, as JavaScript string indices are, so text.slice(start, end) is the selected text.
const text = signal("Select part of this text");
const range = signal({ start: 0, end: 0 });
const selection = signal<{ start: number; end?: number } | undefined>(undefined);
<Input
value={text()}
onChangeText={(t) => text.set(t)}
selection={selection()}
onSelectionChange={(r) => range.set(r)}
/>
<Text>Selected: "{text().slice(range().start, range().end)}"</Text>
<View onPress={() => selection.set({ start: 0, end: text().length })}><Text>Select all</Text></View>
#Multiline inputs that grow
A multiline input scrolls its text inside its own height. To grow it with its content, feed onContentSizeChange’s height back into its style, with a limit:
const height = signal(0);
<Input
multiline
placeholder="Write a comment"
onContentSizeChange={(e) => height.set(Math.min(e.height, 120))}
style={{ fontSize: 16, padding: 10, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 10, height: Math.max(height(), 20) + 20 }}
/>
The reported height is the text’s (at the input’s width), so add the vertical padding yourself, as above.
#Keeping inputs above the keyboard
#Inside scroll views: automatic
When the keyboard appears over a focused input that is inside a <Scroll> or List, nsh gives the scroll view a bottom inset as tall as the keyboard and scrolls the input into view. This is native and needs no code. A long form is simply a scroll view of inputs:
<Scroll style={{ flex: 1 }} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled">
<View style={{ padding: 16, gap: 12 }}>
{/* inputs */}
</View>
</Scroll>
keyboardDismissMode ("none", "on-drag", "interactive") lets dragging the form hide the keyboard; keyboardShouldPersistTaps="handled" lets a tap on a button go through while the keyboard is up instead of only dismissing it. See Scrolling.
#Content outside scroll views: KeyboardAvoidingView
A message bar pinned to the bottom of a screen is not in a scroll view, so nothing moves it. Wrap the screen in KeyboardAvoidingView from nsh/ui/mobile: it makes room at the bottom as tall as the keyboard, animated with the keyboard.
import { Input, Scroll, signal, View } from "nsh";
import { keyboard, KeyboardAvoidingView, useSafeAreaInsets } from "nsh/ui/mobile";
function ChatScreen() {
const insets = useSafeAreaInsets();
const draft = signal("");
return (
<KeyboardAvoidingView style={{ flex: 1 }}>
<Scroll style={{ flex: 1 }} keyboardDismissMode="interactive">
{/* messages */}
</Scroll>
<View
style={{
flexDirection: "row",
padding: 8,
// Above the home indicator, unless the keyboard covers it.
paddingBottom: 8 + (keyboard().height > 0 ? 0 : insets().bottom),
}}
>
<Input value={draft()} onChangeText={(t) => draft.set(t)} placeholder="Message" style={{ flex: 1 }} />
</View>
</KeyboardAvoidingView>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
behavior | "padding" | "height" | "position" | "padding" | padding pads the bottom by the keyboard’s height; height shrinks the view (its background ends at the keyboard); position moves an inner content view up. |
enabled | boolean | true | Avoid the keyboard. |
keyboardVerticalOffset | number | 0 | Added to the keyboard’s height while it shows (a tab bar below the view, say). |
contentContainerStyle | StyleProp<ViewStyle> | The inner view’s style with behavior="position". |
It assumes it reaches the bottom of the surface, as a screen’s content does. On an Android host whose window resizes for the keyboard, the window itself shrinks and KeyboardAvoidingView has nothing to do. See KeyboardAvoidingView.
#The keyboard accessor
keyboard() from nsh/ui/mobile is a reactive { height, duration }: how far the keyboard reaches into the surface (0 when hidden; the bottom safe area under it included) and its animation’s duration in seconds. Read it for anything custom: hide a floating button while typing, or size a panel.
import { View } from "nsh";
import { keyboard } from "nsh/ui/mobile";
<If when={keyboard().height === 0}>
<View style={{ position: "absolute", right: 16, bottom: 16 }}>…</View>
</If>
#A bar over the keyboard (iOS): InputAccessoryView
InputAccessoryView from nsh/ios shows a bar attached to the top of the keyboard while an input that names it has focus: iOS’s inputAccessoryView. It moves with the keyboard natively, including during interactive dismissal. It renders nothing where it is placed in the tree; inputs refer to it by nativeID:
import { Input, signal, Text, View, type YNode } from "nsh";
import { InputAccessoryView } from "nsh/ios";
function Note() {
const text = signal("");
let input!: YNode;
return (
<View style={{ padding: 16 }}>
<Input
ref={input}
value={text()}
onChangeText={(t) => text.set(t)}
ios={{ inputAccessoryViewID: "note-bar" }}
placeholder="A note"
/>
<InputAccessoryView nativeID="note-bar" style={{ backgroundColor: "#f6f8fa" }}>
<View style={{ flexDirection: "row", alignItems: "center", padding: 8 }}>
<Text style={{ flex: 1, color: "#57606a" }}>{text().length} characters</Text>
<Text onPress={() => input.blur()} style={{ color: "#0969da", fontWeight: "600" }}>Done</Text>
</View>
</InputAccessoryView>
</View>
);
}
The bar is as wide as the window and as tall as its content. Android has no input accessories: there InputAccessoryView renders nothing, and isAvailable from nsh/ios is false, so plan another place for those controls (for example above the input) when Platform.OS === "android".