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.

guide-forms.tsx Edit in Playground Open in new tab
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

PropTypeDefaultDescription
valuestringThe text, controlled by JS.
defaultValuestringThe initial text of an uncontrolled input.
placeholderstringShown while the input is empty.
placeholderTextColorColorValueThe placeholder’s color (also placeholder:text-* classes).
secureTextEntrybooleanfalseHides the text (passwords).
keyboardTypeKeyboardType"default"The keyboard layout.
returnKeyTypeReturnKeyType"default"The return key’s label.
autoCapitalize"none" | "sentences" | "words" | "characters""sentences"Automatic capitalization.
autoCorrectbooleantrueThe system’s corrections.
autoCompleteAutoCompleteWhat the input holds, for autofill.
multilinebooleanfalseSeveral lines; return inserts a line break.
maxLengthnumberThe most characters the user can enter.
editablebooleantrueThe user can change the text.
autoFocusbooleanfalseFocused when it appears.
blurOnSubmitbooleantrueReturn unfocuses a single-line input.
caretHiddenbooleanfalseNo caret is drawn.
selectionColorColorValueThe caret and the selection.
selection{ start: number; end?: number }The controlled selection, in UTF-16 units.
allowFontScalingbooleantrueFollow the system text size.
maxFontSizeMultipliernumberThe upper bound of the system scale (0: none).
ios={{ inputAccessoryViewID }}stringiOS: the bar shown over the keyboard (see below).
ios={{ textContentType }}TextContentTypeiOS’s names for autoComplete values.

Events:

EventPayloadWhen
onChangeTexttext: stringThe text changed.
onSubmitEditing{ text }The return key was pressed (single line).
onFocus / onBlurThe 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:

ValueKeyboard
"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:

MethodDescription
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>
  );
}
PropTypeDefaultDescription
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.
enabledbooleantrueAvoid the keyboard.
keyboardVerticalOffsetnumber0Added to the keyboard’s height while it shows (a tab bar below the view, say).
contentContainerStyleStyleProp<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".