Elements

Tooltip

A tooltip is a short hint next to what it is about: what an icon button does, the full text of a truncated label. On the desktops and in desktop browsers it shows once the pointer has rested on its trigger for delay (500 ms), and hides when the pointer leaves or presses the trigger. Phones and tablets show a tooltip only when the app opens it (open). It never takes the pointer or presses, so the trigger works exactly as it does without it.

el-tooltip.tsx
el-tooltip.tsxLive
import { mount, signal, Text, View } from "nsh";
import { type OpenChangeReason, Tooltip } from "nsh/base";
import { desktop, Inspector, Log, Panel, size, Stage, ui, wide } from "./palette";

/** An icon button with a hint of the app's look, above or below it. */
function Tool(props: {
  icon: string;
  label: string;
  hint: string;
  placement: "top" | "bottom";
  pinned: boolean;
  onPress: () => void;
  onOpenChange: (open: boolean, reason: OpenChangeReason) => void;
}) {
  const hovered = signal(false);
  return (
    <View style={{ alignItems: "center", gap: 6 }}>
      <View
        onPress={props.onPress}
        accessibilityLabel={props.label}
        activeStyle={{ opacity: 0.6 }}
        hoverStyle={{ borderColor: ui().accent }}
        style={{
          ...ui().raised,
          width: desktop ? 36 : 52,
          height: desktop ? 36 : 52,
          borderRadius: desktop ? 8 : 14,
          alignItems: "center",
          justifyContent: "center",
        }}
        tooltip={
          <Tooltip
            open={props.pinned || hovered()}
            placement={props.placement}
            offset={8}
            margin={8}
            delay={400}
            onOpenChange={(open, reason) => {
              hovered.set(open);
              props.onOpenChange(open, reason);
            }}
            style={{ backgroundColor: ui().text, borderRadius: 7, paddingHorizontal: 10, paddingVertical: 6 }}
          >
            <Text style={{ fontSize: 13, color: ui().page }}>{props.hint}</Text>
          </Tooltip>
        }
      >
        <Text style={{ fontSize: desktop ? 17 : 22, color: ui().text }}>{props.icon}</Text>
      </View>
      <Text style={{ fontSize: 11, color: ui().faint }}>{props.placement}</Text>
    </View>
  );
}

function App() {
  const events = signal<string[]>([]);
  const pressed = signal("—");
  const pinned = signal(false);
  const log = (name: string) => (open: boolean, reason: OpenChangeReason) =>
    events.set([`${name} ${open ? "shown" : "hidden"} · ${reason}`, ...events()].slice(0, 6));
  const tools = () => (
    <>
      <Tool icon="★" label="Star" hint="Add to favorites" placement="top" pinned={pinned()} onPress={() => pressed.set("Star")} onOpenChange={log("Star")} />
      <Tool icon="↗" label="Share" hint="Share with your team" placement="bottom" pinned={pinned()} onPress={() => pressed.set("Share")} onOpenChange={log("Share")} />
      <Tool icon="✕" label="Delete" hint="Move to the trash" placement="top" pinned={pinned()} onPress={() => pressed.set("Delete")} onOpenChange={log("Delete")} />
    </>
  );
  // The `tooltip` prop of any element: a text in the theme's bubble.
  const pin = () => (
    <View
      tooltip="Shows every tooltip without a pointer"
      onPress={() => pinned.set(!pinned())}
      activeStyle={{ opacity: 0.6 }}
      style={
        wide()
          ? { ...ui().raised, alignSelf: "center", height: size.control, paddingHorizontal: 12, borderRadius: 8, justifyContent: "center" }
          : { ...ui().raised, flex: 1, height: 40, borderRadius: 10, alignItems: "center", justifyContent: "center" }
      }
    >
      <Text style={{ fontSize: desktop ? 13 : 14, fontWeight: "500", color: ui().text }}>
        {wide() ? (pinned() ? "Unpin the tooltips" : "Pin the tooltips") : pinned() ? "Unpin" : "Pin"}
      </Text>
    </View>
  );
  const onPress = () => (
    <Panel title="onPress">
      <Text style={{ fontSize: 13, color: ui().text }}>{pressed()}</Text>
    </Panel>
  );
  const changes = () => <Log title="onOpenChange(open, reason)" lines={events()} minHeight={130} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tooltip</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Rest the pointer on a button; a click still presses it" : "Tooltips follow a pointer: pin them to see them here"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage gap={40}>
              <View style={{ flexDirection: "row", justifyContent: "center", gap: 64 }}>{tools()}</View>
              {pin()}
            </Stage>
            <Inspector>
              {onPress()}
              {changes()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flex: 1, minHeight: 200, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 28 }}>{tools()}</View>
            <View style={{ flexDirection: "row", gap: 8 }}>{pin()}</View>
            {onPress()}
            {changes()}
          </>
        )
      }
    </View>
  );
}

mount(App);

The quickest tooltip is the tooltip prop every element has: a text in a small bubble drawn with the theme’s popover tokens, or an element shown as it is (Common props).

view().on_press(save).tooltip("Save the document").child(text("💾"))

Tooltip from nsh/base is the one to style, place and follow, given as the element’s tooltip (as GPUI’s .tooltip(..)): the element is what the pointer rests on and what it is placed against. The tooltip itself is the bubble: style it with class / style, and its children are what it says.

use nsh_base::tooltip;
use nsh_core::ui::prelude::*;

fn save_button(on_save: impl Fn() + 'static) -> impl IntoElement {
    view()
        .on_press(on_save)
        .accessibility_label("Save")
        .p_2()
        .child(text("💾"))
        .tooltip(
            tooltip()
                .placement(Placement::Bottom)
                .rounded_md()
                .bg(rgb(0x111827))
                .px_2()
                .py_1()
                .child(text("Save the document").text_color(white())),
        )
}

It goes above its trigger by default (placement), on the other side when there is no room, and stays inside the safe area. With open the app decides when it shows: it follows onOpenChange, which reports the pointer resting on the trigger or leaving it, and can show it on any device.

#Props

PropRustTypeDefaultDescription
children.child(..)JSX.ElementnoneWhat it says.
class, style.app_class(..), Styled methodsstring, StyleProp<ViewStyle>noneThe look of the bubble: the tooltip itself.
open.open(..)booleannoneShown, controlled by the app.
defaultOpen.default_open(..)booleanfalseShown at first, without open.
delay.delay(..)number500Milliseconds the pointer rests on the trigger before it shows.
disabled.disabled(..)booleanfalseIt never shows.
placement.placement(..)"top" | "bottom" | "left" | "right""top"The side of the trigger it goes on.
align.align(..)"start" | "center" | "end""center"How it lines up with the trigger along that side.
offset.offset(..)number4The gap between the trigger and the tooltip.
margin.margin(..)number4The distance it keeps from the edges of the safe area.

#Events

EventRustDescription
onOpenChange.on_open_change(|e| ..)The pointer rested on the trigger, or left or pressed it (reason "hover").
onOpen.on_open(|| ..)It is shown.
onClose.on_close(|| ..)It is gone, also when the app hid it.

#Accessibility

A tooltip repeats what its trigger says to screen readers: give the trigger an accessibilityLabel too, so the hint is there for everyone.