Elements

Overlay

An overlay shows its children on top of everything in the root view it is in: the window, or the dialog around it. Its parent’s size, overflow: hidden and scroll views do not clip it. It stays where you wrote it for everything else: it reads the context around it, its handlers are its component’s, and it goes away with its parent. Popovers, menus, tooltips and sheets are built on it; nsh/base has them ready (Popover, Menu, Tooltip, Sheet), and an overlay is what you use to build your own.

el-overlay.tsx
el-overlay.tsxLive
import { mount, Overlay, signal, Text, View, type YNode } from "nsh";
import { desktop, Inspector, Panel, Segmented, size, Stage, ui, wide } from "./palette";

type Placement = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";

function App() {
  const open = signal(false);
  const placement = signal<Placement>("bottom");
  const align = signal<Align>("center");
  const position = signal("—");
  const closed = signal("—");
  let anchor: YNode | undefined;

  const placements = () => (
    <Segmented
      label="placement"
      options={["top", "bottom", "left", "right"] as Placement[]}
      value={placement()}
      onChange={(v) => (placement.set(v), open.set(true))}
    />
  );
  const aligns = () => (
    <Segmented label="align" options={["start", "center", "end"] as Align[]} value={align()} onChange={(v) => (align.set(v), open.set(true))} />
  );
  const subject = () => (
    <>
      <View
        style={{
          width: 220,
          height: 72,
          overflow: "hidden",
          borderRadius: 14,
          borderWidth: 2,
          borderStyle: "dashed",
          borderColor: ui().faint,
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <View
          ref={anchor}
          onPress={() => open.set(!open())}
          activeStyle={{ opacity: 0.7 }}
          style={{
            ...ui().accentFill,
            borderRadius: desktop ? 8 : 10,
            paddingHorizontal: desktop ? 14 : 18,
            height: desktop ? size.control : 40,
            justifyContent: "center",
          }}
        >
          <Text style={{ fontSize: desktop ? 14 : 15, fontWeight: "600", color: ui().onAccent }}>{open() ? "Close" : "Open"}</Text>
        </View>
        <If when={open()}>
          <Overlay
            anchor={anchor}
            placement={placement()}
            align={align()}
            offset={8}
            margin={12}
            onPosition={(e) =>
              position.set(
                `${e.placement} at ${Math.round(e.bounds.origin.x)}, ${Math.round(e.bounds.origin.y)} · ${Math.round(e.bounds.size.width)}×${Math.round(e.bounds.size.height)} · room ${Math.round(e.available.width)}×${Math.round(e.available.height)}`,
              )
            }
            onPressOutside={(e) => {
              closed.set(`onPressOutside at ${Math.round(e.pagePosition.x)}, ${Math.round(e.pagePosition.y)}`);
              open.set(false);
            }}
            onCancel={() => {
              closed.set("onCancel");
              open.set(false);
            }}
            style={{
              ...ui().card,
              borderRadius: 12,
              padding: 14,
              gap: 4,
              // Narrow beside the button, so it fits on a phone's side.
              maxWidth: !wide() && (placement() === "left" || placement() === "right") ? 130 : 220,
            }}
          >
            <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>On top of everything</Text>
            <Text style={{ fontSize: 13, color: ui().muted }}>
              Its box clips the button, not this overlay. Pick a side {wide() ? "on the right" : "above"}.
            </Text>
          </Overlay>
        </If>
      </View>
      <Text style={{ marginTop: 6, fontSize: 12, color: ui().faint }}>The dashed box has overflow: hidden</Text>
    </>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>overlay</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click the button, then click outside or press Escape" : "Tap the button, then tap outside"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage>
              <View style={{ alignItems: "center" }}>{subject()}</View>
            </Stage>
            <Inspector>
              {placements()}
              {aligns()}
              <Panel title="onPosition">
                <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{position()}</Text>
              </Panel>
              <Panel title="closed by" grow>
                <Text style={{ fontSize: 13, color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
              </Panel>
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", gap: 12 }}>
              <View style={{ flex: 3 }}>{placements()}</View>
              <View style={{ flex: 2 }}>{aligns()}</View>
            </View>

            <View style={{ flex: 1, minHeight: 160, alignItems: "center", justifyContent: "center" }}>{subject()}</View>

            <View style={{ ...ui().inset, borderRadius: 12, padding: 12, gap: 6 }}>
              <Text style={{ fontSize: 12, color: ui().muted }}>
                onPosition <Text style={{ color: ui().text }}>{position()}</Text>
              </Text>
              <Text style={{ fontSize: 12, color: ui().muted }}>
                closed by <Text style={{ color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
              </Text>
            </View>
          </>
        )
      }
    </View>
  );
}

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

fn more_button() -> impl IntoElement {
    let open = signal(false);
    let anchor = element_ref();
    view()
        .child(
            view()
                .element_ref(&anchor)
                .on_press(move || open.update(|o| *o = !*o))
                .px_3()
                .py_2()
                .child(text("More")),
        )
        .child(when(move || open.get(), move || {
            overlay()
                .anchor(&anchor)
                .placement(Placement::Bottom)
                .align(Align::Start)
                .offset(px(6.0))
                .margin(px(8.0))
                .on_press_outside(move |_: &Point<Points>| open.set(false))
                .on_cancel(move || open.set(false))
                .rounded_lg()
                .bg(white())
                .p_3()
                .shadow_lg()
                .child(text("Shown above everything"))
        }))
}

Overlay comes from nsh in TSX and overlay() from nsh_core::ui::prelude in Rust. It is shown while it is in the view: put it in an If (Rust: when(..)) to open and close it. Overlays shown later are on top of earlier ones, and an overlay inside another is on top of it.

#Placement

Without anchor, an overlay is laid out as a position: fixed box of its root view: its top, left, inset, size and margins are against the whole window (or dialog). class="inset-0" covers it, which is a backdrop or a full-window layer; class="bottom-0 left-0 right-0" is a bar along the bottom.

With anchor, NSH places it next to the anchor:

  • anchor is a view (a ref), a Point ({ x, y }) in the root view’s coordinates, the pageX / pageY of a touch (a menu where the pointer is), or a Bounds ({ origin, size }) there: an input’s caret (onCaretMove’s pageBounds), where suggestions for what is being typed open.
  • placement is the side it goes on: "top", "bottom" (the default), "left" or "right". When it does not fit there and fits on the other side, it goes there; when it fits on neither, it goes on the side with more room.
  • align lines it up along that side: "start", "center" (the default) or "end". Above and below the anchor, start and end follow the layout direction: start is the left edge, or the right edge in a right-to-left layout.
  • offset is the gap between the anchor and the overlay; margin the distance it keeps from the edges of the safe area, which it stays inside of: it slides along its side to fit.

An anchored overlay follows its anchor: when the anchor scrolls, moves or changes size, the overlay moves with it in the same frame NSH lays it out. onPosition tells where it went, so an arrow can point at the anchor or a long menu can limit its height:

<Overlay
  anchor={anchor}
  onPosition={(e) => {
    side.set(e.placement); // "top" when it flipped
    maxHeight.set(e.available.height);
  }}
/>

#Dismissing

An overlay reports what usually closes a popup and leaves the decision to you:

  • onPressOutside fires when a finger or a mouse button goes down outside the overlay, its anchor and the overlays inside it. The press goes on to what is under it.
  • onCancel fires on Escape, the Android back button and VoiceOver’s escape gesture on iOS, for the top overlay that listens to it only. It takes them before the pages and BackHandler.

To block the screen behind a popup, put a second overlay under it with class="inset-0" and an onPress that closes both: that is how Sheet and the phone presentation of Popover work.

#Props

PropRustTypeDefaultDescription
anchor.anchor(&element_ref), .anchor_point(point), .anchor_bounds(bounds)YNode | Point | Bounds | nullnoneThe view, the point or the box it is placed against. Without one it is a fixed box of its root view.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"The side of the anchor it goes on.
align.align(..)"start" | "center" | "end""center"How it lines up with the anchor along that side.
offset.offset(px(..))number0The gap between the anchor and the overlay.
margin.margin(px(..))number0The distance it keeps from the edges of the safe area.
children.child(..)JSX.ElementnoneWhat it shows.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneIts look and, without an anchor, where it is.

It also takes the common props of a view.

#Events

EventRustDescription
onPressOutside.on_press_outside(|point| ..)A press went down outside it, its anchor and the overlays inside it (pagePosition, a Point).
onCancel.on_cancel(|| ..)Escape, the Android back button or VoiceOver’s escape gesture, while it is the top overlay listening.
onPosition.on_position(|position| ..)NSH placed it: placement (the side it went on), bounds in the root view, and available (a Size: the room on that side).

#Platform notes

  • Every platform shows an overlay as a native view of the window’s (or the dialog’s) root view, drawn and hit-tested as the views around it are: UIKit, Android views, AppKit, GTK, Win32, GPUI and the DOM.
  • An overlay inside a Dialog shows in that dialog, above its content.
  • Overlays stay in their window: a menu that would reach past the window’s edge slides back inside it.