Elements

KeyboardAvoidingView

KeyboardAvoidingView keeps its content above the software keyboard. When the keyboard shows or hides, the view makes room by the keyboard’s 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-avoiding-view.tsx
el-keyboard-avoiding-view.tsxLive
import { Input, mount, Scroll, signal, Text, View } from "nsh";
import { Button, Keyboard, Switch, KeyboardAvoidingView, keyboardFrame } from "nsh/base";
import { desktop, 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) => ({
    ...(on ? ui().accentFill : ui().raised),
    borderRadius: 999,
    paddingHorizontal: 12,
    paddingVertical: 6,
  });
  const chipText = (on: boolean) => ({ color: on ? ui().onAccent : ui().text, fontSize: 13, fontWeight: "600" as const });
  return (
    <KeyboardAvoidingView
      enabled={enabled()}
      behavior={behavior()}
      style={{ flex: 1, backgroundColor: behavior() === "height" ? tint(ui().info, 0.12) : ui().page }}
    >
      <View style={{ padding: 20, paddingTop: 24, paddingBottom: 8, gap: 8 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>keyboard-avoiding-view</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={behavior() === "padding"} style={chip(behavior() === "padding")}>
            <Text style={chipText(behavior() === "padding")}>padding</Text>
          </Button>
          <Button onPress={() => behavior.set("height")} selected={behavior() === "height"} style={chip(behavior() === "height")}>
            <Text style={chipText(behavior() === "height")}>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"
            trackStyle={enabled() ? ui().accentFill : ui().inset}
            thumbClass="size-4 rounded-full transition-transform 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"
          style={{ ...ui().inset, flex: 1, fontSize: 16, color: ui().text, placeholderColor: ui().faint, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 8 }}
        />
        <Button onPress={send} style={{ ...ui().primary, borderRadius: 10, paddingHorizontal: 14, justifyContent: "center" }} activeStyle={{ opacity: 0.7 }}>
          <Text style={{ color: ui().onPrimary, fontWeight: "600" }}>Send</Text>
        </Button>
        <Button onPress={() => Keyboard.dismiss()} style={{ ...ui().raised, borderRadius: 10, paddingHorizontal: 12, justifyContent: "center" }} activeStyle={{ opacity: 0.7 }}>
          <Text style={{ color: ui().text, fontWeight: "600" }}>Done</Text>
        </Button>
      </View>
    </KeyboardAvoidingView>
  );
}

mount(App);
use nsh_base::keyboard_avoiding_view;
use nsh_core::ui::prelude::*;

fn chat() -> impl IntoElement {
    let draft = signal(String::new());
    keyboard_avoiding_view()
        .flex_1()
        .bg(white())
        .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 view assumes that it reaches the bottom of the screen, as a screen’s content does: it makes room by the full keyboard height. When something is below it, such as a tab bar, subtract that with offset (a negative value) or add to it with a positive value.

#Props

PropRustTypeDefaultDescription
enabled.enabled(..)booleantrueAvoid the keyboard. While false, the view keeps its own layout.
behavior.behavior(AvoidBehavior::Height)"padding" | "height""padding"How the view makes room (see Behaviors).
offset.offset(px(..))number0Added to the keyboard’s height while it shows (for example, minus the height of a bar below the view). The total is at least 0.
children.child(..)JSX.ElementnoneThe content.
class, classList, style, testID, onLayout, refstyle methods, .test_id(..)Applied to the outer view.

#Behaviors

behaviorHow it makes room
"padding" (default)Adds the keyboard’s height to the view’s bottom padding. The content above shrinks; the background still reaches the bottom of the screen.
"height"Adds the keyboard’s height as a bottom margin. The whole view, including its background, ends at the top of the keyboard.

The padding or margin is added to what the view’s own style sets, so your padding stays.

use nsh_base::{AvoidBehavior, keyboard_avoiding_view};
use nsh_core::ui::prelude::*;

fn sign_in() -> impl IntoElement {
    keyboard_avoiding_view()
        .behavior(AvoidBehavior::Height)
        .offset(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.

#Hiding the keyboard

Keyboard.dismiss() 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::ui::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::ui::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 KeyboardAvoidingView keeps its layout. Where the keyboard overlaps the view, it makes room as on iOS.
  • Desktops and Mac Catalyst type with a hardware keyboard: the keyboard’s height stays 0 and the view keeps its layout.

See Forms and keyboard for focus order, return keys and input accessories.