Elements

Keyboard

env(keyboard-inset-height) is the software keyboard’s height over the surface, 0 while it is hidden. As a view’s bottom padding or margin it keeps the content above the keyboard: when the keyboard shows or hides, the view makes room by its height, and the change animates with the keyboard natively. Use it for content outside scroll views, such as a message bar at the bottom of a chat screen; inputs inside a scroll view or a List are scrolled into view natively. With it come Keyboard.dismiss(), which hides the keyboard, and the keyboard’s height, for layouts of your own.

el-keyboard.tsx
el-keyboard.tsxLive
import { mount, Scroll, signal, Text, View } from "nsh";
import { Input } from "nsh/base";
import { Button, Switch } from "nsh/base";
import { Keyboard, keyboardFrame } from "nsh";
import { button, buttonText, desktop, field, tint, ui } from "./palette";

function App() {
  const draft = signal("");
  const messages = signal(["Type below: the bar stays above the keyboard."]);
  const behavior = signal<"padding" | "height">("padding");
  const enabled = signal(true);
  const send = () => {
    if (!draft().trim()) return;
    messages.update((list) => [...list, draft().trim()]);
    draft.set("");
  };
  const chip = (on: () => boolean) => button("secondary", { on, size: "sm" });
  const chipText = (on: () => boolean) => buttonText("secondary", { on });
  const isPadding = () => behavior() === "padding";
  const isHeight = () => behavior() === "height";
  return (
    <View
      style={{
        flex: 1,
        backgroundColor: behavior() === "height" ? tint(ui().info, 0.12) : ui().page,
        // Bottom padding (or margin) by the keyboard's height, while enabled.
        ...(!enabled() ? {} : behavior() === "padding" ? { paddingBottom: "env(keyboard-inset-height)" } : { marginBottom: "env(keyboard-inset-height)" }),
      }}
    >
      <View style={{ padding: 20, paddingTop: 24, paddingBottom: 8, gap: 8 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>keyboard inset</Text>
        <Text style={{ color: ui().muted }}>
          {desktop
            ? "Desktops type with a hardware keyboard: its height stays 0 and the view keeps its layout. Open this on a phone to see it move."
            : "Tap the message field: the bar moves up with the keyboard."}
        </Text>
        <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
          <Button onPress={() => behavior.set("padding")} selected={isPadding()} {...chip(isPadding)}>
            <Text style={chipText(isPadding)}>padding</Text>
          </Button>
          <Button onPress={() => behavior.set("height")} selected={isHeight()} {...chip(isHeight)}>
            <Text style={chipText(isHeight)}>height</Text>
          </Button>
          <Switch
            checked={enabled()}
            onChange={(next) => enabled.set(next)}
            class="flex-row items-center gap-2 px-1 active:opacity-70"
            trackClass="w-9 rounded-full p-0.5 transition-colors motion-reduce:transition-none"
            trackStyle={enabled() ? ui().accentFill : ui().inset}
            thumbClass="size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4"
            thumbStyle={enabled() ? { backgroundColor: ui().onAccent } : ui().raised}
          >
            <Text style={{ color: ui().text, fontSize: 13, fontWeight: "600" }}>enabled {String(enabled())}</Text>
          </Switch>
        </View>
        <Text style={{ color: ui().accent, fontSize: 13 }}>
          keyboardFrame(): height {Math.round(keyboardFrame().height)} · duration {keyboardFrame().duration.toFixed(2)} s
        </Text>
      </View>

      <Scroll style={{ flex: 1 }} keyboardDismissMode="interactive">
        <View style={{ padding: 20, paddingTop: 4, gap: 8 }}>
          <Each of={messages()}>
            {(text) => (
              <View style={{ ...ui().row, alignSelf: "flex-start", maxWidth: 320, borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 }}>
                <Text style={{ color: ui().text }}>{text()}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>

      <View style={{ flexDirection: "row", gap: 8, padding: 12, paddingHorizontal: desktop ? 20 : 12, borderTopWidth: 1, borderTopColor: ui().border, backgroundColor: ui().page }}>
        <Input
          value={draft()}
          onChange={(text) => draft.set(text)}
          onSubmit={send}
          testID="message"
          returnKeyType="send"
          placeholder="Message"
          {...field({ style: { flex: 1 } })}
        />
        <Button onPress={send} {...button("primary")}>
          <Text style={buttonText("primary")}>Send</Text>
        </Button>
        <Button onPress={() => Keyboard.dismiss()} {...button()}>
          <Text style={buttonText()}>Done</Text>
        </Button>
      </View>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

fn chat() -> impl IntoElement {
    let draft = signal(String::new());
    view()
        .flex_1()
        .bg(white())
        .pb_keyboard()
        .child(scroll().flex_1().child(text("Messages…").p_4()))
        .child(
            view()
                .flex_row()
                .gap_2()
                .p_2()
                .border_t_1()
                .border_color(rgb(0xe5e5e5))
                .child(
                    input()
                        .value(draft)
                        .on_change(move |v: &str| draft.set(v.to_owned()))
                        .placeholder("Message")
                        .flex_1()
                        .p_2(),
                ),
        )
}

The full keyboard height suits a view that reaches the bottom of the screen, as a screen’s content does. When something is below it, such as a tab bar, subtract that with a length: calc(env(keyboard-inset-height) + -49px) keeps a view above a 49 pt tab bar, and calc(env(keyboard-inset-height) + 12px) adds spacing above the keyboard. A padding is at least 0.

#Padding or margin

StyleHow it makes room
paddingBottom: "env(keyboard-inset-height)" (Tailwind pb-[env(keyboard-inset-height)], Rust .pb_keyboard())The content above shrinks; the background still reaches the bottom of the screen.
marginBottom: "env(keyboard-inset-height)" (Tailwind mb-[env(keyboard-inset-height)], Rust .mb_keyboard())The whole view, including its background, ends at the top of the keyboard.

The keyboard’s height is added to the length in the same declaration only, so a view combines it with spacing of its own in calc(). In Rust, .pb_keyboard_plus(px(..)) and .mb_keyboard_plus(px(..)) add a length.

use nsh_core::prelude::*;

fn sign_in() -> impl IntoElement {
    view()
        .mb_keyboard_plus(px(-49.0))
        .flex_1()
        .p_4()
        .gap_3()
        .child(input().placeholder("E-mail").keyboard_type(KeyboardType::Email))
}

Here a 49 pt tab bar below the view already covers part of the keyboard’s height. To turn the room off, leave the variable out of the style, for example with a condition on a signal.

#Hiding the keyboard

Keyboard.dismiss(), from nsh, unfocuses the focused input, which hides the keyboard. With no input focused, it leaves everything as it is. Rust components call dismiss_keyboard().

use nsh_base::button;
use nsh_core::prelude::*;

fn done() -> impl IntoElement {
    button()
        .on_press(|| dismiss_keyboard())
        .rounded_lg()
        .bg(rgb(0x2563eb))
        .px_4()
        .py_3()
        .child(text("Done").text_color(white()))
}

#The keyboard’s height

TSXRustReturns
keyboardFrame()keyboard_height(), keyboard_duration()The height of the software keyboard over the NSH surface, in points (0 while it is hidden), and the duration of its show or hide animation, in seconds.

Both are reactive: an effect, a JSX expression or a style closure that reads them runs again when the keyboard shows, hides or changes height.

use nsh_core::prelude::*;

fn message_bar() -> impl IntoElement {
    view()
        .flex_row()
        .p_2()
        .pb(move || {
            // The home indicator's inset only matters while the keyboard is hidden.
            let home = if keyboard_height() > Points::ZERO {
                Points::ZERO
            } else {
                safe_area().bottom
            };
            px(8.0) + home
        })
        .child(input().placeholder("Message").flex_1())
}

The height is the part of the keyboard that overlaps the NSH surface. When the window itself shrinks for the keyboard (an Android activity that resizes), or when NSH is embedded in a view that ends above the keyboard, the height is 0.

#Platform notes

  • iOS: the room animates with the keyboard’s own duration and an ease-in-ease-out curve.
  • Android: where the window shrinks for the keyboard (an activity that resizes), the content already ends above the keyboard and the variable is 0. Where the keyboard overlaps the view, it makes room as on iOS.
  • Desktops and Mac Catalyst type with a hardware keyboard: the variable is 0 and the view keeps its layout.
  • Web: the variable is the browser’s own (the VirtualKeyboard API’s).

See Forms and keyboard for focus order, return keys and input accessories, and Safe area for the other environment variables.