Elements

Toast

Toasts are short messages shown for a while over the app: “Copied”, “Draft deleted · Undo”, “Could not send”. nsh/base provides their behavior in three unstyled pieces, the same as GPUI Kit’s gpui-base:

el-toast.tsx
el-toast.tsxLive
import { mount, Overlay, signal, Text, View } from "nsh";
import { safeArea, Toast, type ToastEntry, ToastManager, ToastStack } from "nsh/base";
import { desktop, Log, size, ui, wide } from "./palette";

/** What the demo's toasts show. */
type Content = { title: string; description?: string; undo?: boolean };

function App() {
  const manager = new ToastManager();
  const shown = signal<ToastEntry[]>([]);
  const expanded = signal(false);
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 6));
  const sync = () => shown.set(manager.visible(3));
  // The lifecycle advances on the app's timer, paused while the stack is expanded.
  setInterval(() => {
    const a = manager.advance(expanded());
    for (const id of a.presented) log(`presented · ${id}`);
    for (const id of a.ending) log(`ending · ${id}`);
    for (const r of a.removed) log(`removed · ${r.id}`);
    if (a.changed) sync();
  }, 50);
  let count = 0;
  const push = (content: Content) => {
    const id = `toast-${++count}`;
    manager.push(id, content, { timeout: 5000 });
    log(`push · ${id}`);
    sync();
  };
  const dismiss = (id: string) => {
    manager.dismiss(id);
    sync();
  };
  /** A button of the demo. */
  const button = (label: string, onPress: () => void) => (
    <View
      onPress={onPress}
      activeStyle={{ opacity: 0.7 }}
      style={{ ...ui().raised, height: wide() ? size.control : 42, paddingHorizontal: 14, borderRadius: wide() ? 8 : 10, justifyContent: "center" }}
    >
      <Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: ui().text }}>{label}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toast</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Show a few; rest the pointer on the stack to expand it and stop their time" : "Show a few; touch the stack to expand it, swipe one right to dismiss it"}
      </Text>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
        {button("Copy the link", () => push({ title: "Copied", description: "The link is on the clipboard" }))}
        {button("Delete the draft", () => push({ title: "Draft deleted", undo: true }))}
        {button("Dismiss all", () => {
          manager.dismissAll();
          sync();
        })}
      </View>
      <Log title="ToastManager.advance · onExpandedChange" lines={events()} minHeight={130} />

      <Overlay pointerEvents="box-none" style={{ left: 0, right: 0, bottom: 0, padding: 16, paddingBottom: safeArea().bottom + 16, alignItems: wide() ? "flex-end" : "center" }}>
        <ToastStack
          placement={wide() ? "bottomRight" : "bottomCenter"}
          style={{ width: 340, maxWidth: "100%" }}
          onExpandedChange={(open) => {
            expanded.set(open);
            log(`onExpandedChange · ${open}`);
          }}
          onCancel={() => {
            const newest = shown().filter((t) => t.status !== "ending").at(-1);
            if (newest) dismiss(newest.id);
          }}
        >
          <Each of={shown()} key={(t: ToastEntry) => t.id}>
            {(t) => {
              const content = () => t().value as Content;
              return (
                <Toast
                  transitionStatus={t().status}
                  accessibilityLabel={content().title}
                  onDismissRequest={() => dismiss(t().id)}
                  class="transition-all duration-200 ease-out"
                  startingClass="opacity-0 translate-y-4"
                  endingClass="opacity-0 scale-95"
                  swipingClass="transition-none"
                  style={{ ...ui().card, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}
                >
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: 14, fontWeight: "600", color: ui().text }}>{content().title}</Text>
                    <If when={content().description}>
                      <Text style={{ fontSize: 13, color: ui().muted }}>{content().description}</Text>
                    </If>
                  </View>
                  <If when={content().undo}>
                    <View onPress={() => dismiss(t().id)} activeStyle={{ opacity: 0.7 }} style={{ ...ui().primary, height: 28, paddingHorizontal: 10, borderRadius: 8, justifyContent: "center" }}>
                      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().onPrimary }}>Undo</Text>
                    </View>
                  </If>
                </Toast>
              );
            }}
          </Each>
        </ToastStack>
      </Overlay>
    </View>
  );
}

mount(App);
  • ToastManager keeps the toasts in order with their lifecycle: each one is "starting", then "present" once the motion’s duration has passed, counts its timeout down while present and not paused, and stays "ending" for the exit’s duration before it is removed.
  • ToastStack measures its toasts and lays them out from an edge, the newest nearest it: overlapping layers while collapsed, a list while expanded (the pointer on it, or a finger down on it), moved with springs.
  • Toast is each toast’s root: the alert role, its transitionStatus for the app’s styles, a swipe that asks to dismiss it, and the screen reader reading its label as it shows.

The look and the content are the app’s, and so is the convenience of showing a toast from anywhere: an app builds its own toast(...) on these, as the gallery does (examples/gallery/toast.tsx) and as GPUI Kit’s notifications are built on gpui-base’s toasts.

use std::time::Duration;

use nsh_core::{time::now, ui::prelude::*};

use nsh_base::{
    Anchor, ToastManager, ToastMotion, ToastOptions, ToastStackState, theme, toast, toast_stack,
};

fn toasts() -> impl IntoElement {
    let manager = stored(ToastManager::<u64, SharedString>::new(ToastMotion::sonner()));
    let shown = signal(Vec::new());
    let state = ToastStackState::new();
    let sync = move || {
        shown.set(manager.with(|m| {
            m.visible(3)
                .map(|(id, t, s)| (*id, t.clone(), s))
                .collect::<Vec<_>>()
        }))
    };
    // Advance the lifecycle while toasts are mounted, paused while expanded.
    set_interval(Duration::from_millis(50), move || {
        if manager.with(|m| m.advance(now(), state.is_expanded()).changed) {
            sync();
        }
    });
    manager.with(|m| {
        m.push(
            1,
            "Copied".into(),
            ToastOptions::timeout(Some(Duration::from_secs(5))),
            now(),
        )
    });
    sync();
    toast_stack()
        .placement(Anchor::BottomCenter)
        .state(state)
        .w(px(340.0))
        .child(each(
            shown,
            |(id, _, _)| *id,
            move |item, _| {
                toast()
                    .transition_status(move || item.get().2)
                    .accessibility_label(move || Some(item.get().1))
                    .on_dismiss_request(move || {
                        manager.with(|m| m.dismiss(&item.get().0, now()));
                    })
                    .rounded_xl()
                    .px_4()
                    .py_3()
                    .bg(move || theme().card)
                    .shadow_lg()
                    .transition_all()
                    .duration(200.0)
                    .starting_class("opacity-0 translate-y-4")
                    .ending_class("opacity-0 scale-95")
                    .child(text(move || item.get().1))
            },
        ))
}

Toast, ToastStack and ToastManager come from nsh/base. In Rust import toast by name: the prelude’s toast is Android’s system toast (Toast.show of nsh/android).

#ToastManager

The manager is headless: it renders nothing and runs no timer. The app pushes toasts, renders the ones visible(limit) returns with their status, and calls advance on a timer while any is mounted, passing paused while the stack is expanded. In Rust it is ToastManager<I, T> over the app’s ids and values, with an Instant for each call; in TS it is a class over string ids and any JSON value, on the engine’s clock (a test’s manual clock included).

  • push(id, value, options) adds the newest toast, replacing one with the same id (whose value it returns); options.timeout is its present time in milliseconds (none: it stays until dismissed).
  • dismiss(id) begins a toast’s exit; dismissAll() every toast’s, returning their ids.
  • advance(paused) moves the lifecycle on and reports what changed: presented, ending (their timeout ran out), removed (with their values), and changed.
  • visible(limit) is the newest limit toasts that are not ending plus the ending ones, oldest first; entries() (Rust iter()) all of them; get(id), len(), isEmpty().

ToastMotion holds the tokens of both: duration (400 ms: the enter’s deadline and the stack’s spring response), exitDuration (200 ms), collapsedPeek (14), expandedGap (14), collapsedScaleStep (0.05) and collapsedVisible (3), Sonner’s (ToastMotion::sonner() in Rust; fields left out in TS keep them).

#ToastStack

The stack’s children are its toasts, oldest first. From its placement edge ("topRight" by default; the bottom anchors lay out upward), collapsed toasts overlap collapsedPeek apart, each further one narrower by collapsedScaleStep, and only collapsedVisible show; expanded, they are a list expandedGap apart. Toasts that arrive or leave, and expansion, move them with critically damped springs. It is expanded while the pointer is on it or a finger is down on it (onExpandedChange(expanded); Rust ToastStackState, whose is_expanded() reads it), so the app pauses the timeouts while the user reads or touches the toasts.

The app places the stack: in an Overlay at the edge, inside the safe area. Its class and style give its width. While it holds toasts it hears Escape and the Android back button (onCancel; closeOnEscape={false} leaves them to the rest of the app): dismiss the newest.

#Toast

A toast is the alert role around the app’s content. Its transitionStatus is the manager’s, and its state classes follow it over its class: startingClass in its first frame while "starting", so its transition classes animate from it (Tailwind’s starting:, CSS’s @starting-style); endingClass while "ending", for the exit’s duration; swipingClass while a pointer swipes it (transition-none keeps it under the finger).

A finger or the mouse dragged toward swipeDirection ("right" by default; null turns swiping off) moves it, once the pointer has moved, so its buttons stay buttons; let go past swipeThreshold points (50) or flicked, it moves on out and asks to be dismissed (onDismissRequest), short of it, it goes back. The screen reader reads its accessibilityLabel as it shows.

#Toast props

PropRustTypeDefaultDescription
transitionStatus.transition_status(..)"starting" | "present" | "ending""starting"Its lifecycle phase.
startingClass, endingClass, swipingClass.starting_class(..), …stringnoneIts classes in its first frame, while ending, while swiped.
swipeDirection.swipe_direction(..)"right" | "left" | "up" | "down" | null"right"The direction it is swiped away in.
swipeThreshold.swipe_threshold(..)number50How far a swipe goes before a release asks to dismiss it.
accessibilityLabel.accessibility_label(..)stringnoneWhat the screen reader reads as it shows.
onDismissRequest.on_dismiss_request(..)() => voidnoneThe user swiped it away.

#ToastStack props

PropRustTypeDefaultDescription
motion.motion(..)ToastMotionSonner’sIts motion tokens.
placement.placement(..)"topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter""topRight"The edge it lays out from.
closeOnEscape.close_on_escape(..)booleantrueIt hears Escape and back while it holds toasts.
onExpandedChange.on_expanded_change(..), .state(..)(expanded: boolean) => voidnoneInteraction expanded or collapsed it.
onCancel.on_cancel(..)() => voidnoneEscape or back: dismiss the newest.