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. On every device it also shows at once while the keyboard focus is on its trigger, and hides when the focus leaves or Escape is pressed, so keyboard users read the same hints. Phones and tablets otherwise 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 { button, buttonText, createPresence, desktop, Inspector, Log, Panel, 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: (e: { open: boolean; reason: OpenChangeReason }) => void;
}) {
  const hovered = signal(false);
  // The bubble stays made between showings: it comes in from its side as it shows.
  const presence = createPresence(props.placement);
  return (
    <View style={{ alignItems: "center", gap: 6 }}>
      <View
        onPress={props.onPress}
        accessibilityLabel={props.label}
        {...button("secondary", { align: "center", style: { width: desktop ? 36 : 52, height: desktop ? 36 : 52, paddingHorizontal: 0 } })}
        tooltip={
          <Tooltip
            open={props.pinned || hovered()}
            placement={props.placement}
            offset={8}
            margin={8}
            delay={400}
            onOpenChange={(e) => {
              hovered.set(e.open);
              props.onOpenChange(e);
            }}
            onOpen={presence.show}
            onClose={presence.hide}
          >
            <View style={{ backgroundColor: ui().text, borderRadius: 7, paddingHorizontal: 10, paddingVertical: 6, ...presence.style }}>
              <Text style={{ fontSize: 13, color: ui().page }}>{props.hint}</Text>
            </View>
          </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, reason }: { 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 `Tooltip` with the app's look.
  const pin = () => (
    <View
      tooltip={
        <Tooltip style={{ backgroundColor: ui().text, borderRadius: 7, paddingHorizontal: 10, paddingVertical: 6 }}>
          <Text style={{ fontSize: 13, color: ui().page }}>Shows every tooltip without a pointer</Text>
        </Tooltip>
      }
      onPress={() => pinned.set(!pinned())}
      {...button("secondary", { align: "center", style: wide() ? {} : { flex: 1 } })}
    >
      <Text style={buttonText()}>
        {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);

Tooltip from nsh/base is given as the tooltip prop every element has (Common props), 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::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 the keyboard focus coming and going, and can show it on any device.

In a terminal a tooltip is a line, as terminal apps show a hint: what it says shows at once on the bottom row of the screen, in the terminal’s own colors, while the keyboard focus or the pointer is on its trigger, and the row clears when they leave. The pointer’s hint shows over the focus’s while the pointer is on its trigger; a toast’s message takes the row while it shows. delay is not waited for there (Infinity still keeps it to the app’s open), and the tooltip’s own look and transitions apply only to the bubble the other devices show; its children, a shortcut’s Kbd included, show as they are.

It comes and goes with your transitions: startingClass is its look in its first frame and endingClass its look once hidden, which it keeps while the transition-* classes of its class run; onClose follows.

<Tooltip
  class="rounded-md bg-gray-900 px-2 py-1 transition-opacity duration-100"
  startingClass="opacity-0"
  endingClass="opacity-0"
>
  <Text class="text-white">Save the document</Text>
</Tooltip>

#Props

PropRustTypeDefaultDescription
children.child(..)JSX.ElementnoneWhat it says.
class, style.app_class(..), Styled methodsstring, StyleProp<ViewStyle>noneThe look of the bubble: the tooltip itself.
startingClass, startingStyle.starting_class(..), .starting_style(..)string, StyleProp<ViewStyle>noneIts look in its first frame: its transitions run from it.
endingClass, endingStyle.ending_class(..), .ending_style(..)string, StyleProp<ViewStyle>noneIts look once hidden: it stays while its transitions run.
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 (the keyboard focus shows it at once); Infinity: never by itself, neither for the pointer nor the focus, only when the app opens it.
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"); the keyboard focus came to it or left it ("focus"); Escape was pressed while it showed ("cancel").
onOpen.on_open(|| ..)It is shown.
onClose.on_close(|| ..)It is gone, after its exit; 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.