Elements

SafeAreaView

SafeAreaView keeps its content clear of the notch or Dynamic Island, the status bar, the home indicator, a navigation header and the Mac Catalyst title bar. It pads itself by the insets where it renders: inside a NavPage these are the insets below the page’s header, inside a Dialog the dialog’s insets, and elsewhere the surface’s safe area. It has no look of its own; its background and padding are the app’s.

el-safe-area-view.tsx
el-safe-area-view.tsxLive
import { mount, signal, Text, View } from "nsh";
import { type Edge, SafeAreaView, safeArea, safeAreaInsets } from "nsh/base";
import { desktop, size, tint, ui } from "./palette";

const EDGES: Edge[] = ["top", "right", "bottom", "left"];

const format = (i: { top: number; right: number; bottom: number; left: number }) =>
  `top ${Math.round(i.top)} · right ${Math.round(i.right)} · bottom ${Math.round(i.bottom)} · left ${Math.round(i.left)}`;

// Called in a component: the insets where it renders (the root here, so the
// surface's safe area; inside a page or a dialog, theirs).
function Insets() {
  const insets = safeAreaInsets();
  return <Text style={{ color: ui().text, fontVariant: ["tabular-nums"] }}>{format(insets())}</Text>;
}

function App() {
  const edges = signal<Edge[]>([...EDGES]);
  const toggle = (edge: Edge) => edges.update((list) => (list.includes(edge) ? list.filter((e) => e !== edge) : EDGES.filter((e) => e === edge || list.includes(e))));
  const label = () => ({ fontSize: 12, color: ui().muted });
  return (
    // The tinted band around the card is SafeAreaView's padding: the insets of
    // the edges chosen, plus its own 16 pt.
    <SafeAreaView edges={edges()} style={{ flex: 1, backgroundColor: tint(ui().accent, 0.16), padding: 16 }}>
      <View
        style={{
          flex: 1,
          width: "100%",
          maxWidth: 560,
          alignSelf: "center",
          backgroundColor: ui().page,
          borderRadius: 16,
          borderWidth: 1,
          borderStyle: "dashed",
          borderColor: ui().faint,
          padding: 20,
          gap: 14,
        }}
      >
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>safe area view</Text>
        <Text style={{ color: ui().muted, lineHeight: 20 }}>
          {desktop
            ? "A desktop window or a browser page has no system UI over it: the insets are 0, so SafeAreaView pads only by its own 16 pt (the tinted band)."
            : "On a phone the insets are the status bar or notch on top and the home indicator below; SafeAreaView adds them to its own 16 pt. This browser frame has none, so they are 0 here."}
        </Text>

        <Text style={label()}>edges ({desktop ? "click" : "tap"} to toggle)</Text>
        <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
          <Each of={EDGES}>
            {(edge) => {
              const on = () => edges().includes(edge());
              return (
                <View
                  role="button"
                  onPress={() => toggle(edge())}
                  activeStyle={{ opacity: 0.6 }}
                  style={{
                    ...(on() ? ui().accentFill : ui().raised),
                    ...(desktop ? { borderRadius: 8, height: size.control, paddingHorizontal: 12, justifyContent: "center" } : { borderRadius: 10, paddingHorizontal: 14, paddingVertical: 8 }),
                  }}
                >
                  <Text style={{ color: on() ? ui().onAccent : ui().muted, fontWeight: "600", fontSize: desktop ? 13 : undefined }}>{edge()}</Text>
                </View>
              );
            }}
          </Each>
        </View>

        <View style={{ ...ui().inset, borderRadius: 12, padding: 14, gap: 6 }}>
          <Text style={label()}>edges</Text>
          <Text style={{ color: ui().text }}>{edges().length ? `[${edges().map((e) => `"${e}"`).join(", ")}]` : "[] (no inset padded)"}</Text>
          <Text style={label()}>safeArea() · the surface's insets</Text>
          <Text style={{ color: ui().text, fontVariant: ["tabular-nums"] }}>{format(safeArea())}</Text>
          <Text style={label()}>safeAreaInsets() · where it renders</Text>
          <Insets />
        </View>
      </View>
    </SafeAreaView>
  );
}

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

fn app() -> impl IntoElement {
    safe_area_view()
        .flex_1()
        .bg(rgb(0xfff8c5))
        .p_4()
        .child(text("Clear of the notch").text_2xl().font_weight(FontWeight::BOLD))
}

The insets are added to the view’s own padding. In the example, the content starts 16 pt below the top inset, and the background color fills the whole area, including the area behind the status bar.

#Props

PropRustTypeDefaultDescription
edges.edges(vec![Edge::Top, ..])Edge[] | null ("top" | "right" | "bottom" | "left")all fourThe edges to pad.
children.child(..)JSX.ElementnoneThe content.
class, classList, style, testID, onLayout, refstyle methods, .test_id(..)Applied to the view. Its padding is added to the insets.

#Choosing the edges

By default all four edges are padded. Pass edges to pad only some of them, for example when a tab bar or a bottom bar of your own handles the bottom inset:

use nsh_base::{Edge, safe_area_view};

safe_area_view()
    .edges(vec![Edge::Top, Edge::Left, Edge::Right])
    .flex_1()

#Where to use it

A NavPage with a visible, opaque header already places its content below the header, so the top inset there is 0. SafeAreaView is for:

  • the root of an app without a navigation stack,
  • a page with headerVisible={false} or headerTransparent,
  • the content of a full-screen Dialog.

#safeArea and safeAreaInsets

When you need the numbers, for example to pad a bottom bar yourself, read them directly. Both TSX accessors return an Edges object ({ top, right, bottom, left } in points) and are reactive: code that reads them runs again when they change, for example on rotation.

TSXRustReturns
safeArea()safe_area()The surface’s safe area: the parts clear of system UI (notch, home indicator, title bar).
safeAreaInsets()safe_area_view(), cx.insets()The insets where the component renders: the nearest page’s (below its header), the nearest dialog’s, or else the surface’s safe area. These are the insets SafeAreaView uses. In TSX, call it in a component; it returns an accessor. In Rust, safe_area_view() applies them to its content and a component of your crate reads them with cx.insets().
use nsh_core::ui::prelude::*;

fn bottom_bar() -> impl IntoElement {
    view()
        .flex_row()
        .p_2()
        .pb(move || px(8.0) + safe_area().bottom)
        .child(text("Bottom bar"))
}

Reading safe_area() (TSX: safeArea()) inside a closure, as above, subscribes the style to them: it updates when the insets change.

#Platform notes

  • iOS: the insets come from UIKit’s safe area: the status bar, the notch or Dynamic Island, and the home indicator.
  • Android: NSH reports the parts of the system bars and the display cutout that overlap the NSH view. When NSH is embedded below a native toolbar, the view’s top inset is 0.
  • Mac Catalyst: the top inset is the window’s title bar.
  • Desktops (macOS, Windows, Linux, GPUI) and the web: a page’s content starts below its header, and a dialog gives its content insets of its own, as on the phones.