Skip to content

Popovers and tooltips

The Radix popover wrapper and the single host that draws every tooltip.

Two surfaces float over the chrome, and neither is built by hand: a popover is Radix under a thin wrapper, and every tooltip is one element that moves.

Popovers are Radix, never hand-rolled

The rule is in standards/04-web.md: "Popovers, dropdowns, and context menus use Radix. Do not hand-roll portals, anchoring, or flip logic." Collision handling is the reason: a panel opened near the window edge has to flip and shift, and hand-rolling that is how a menu ends up half off the screen.

mnemo-web/src/components/ui/popover.tsx is the wrapper. Three exports are Radix parts renamed; only the content is wrapped.

export const Popover = RadixPopover.Root
export const PopoverTrigger = RadixPopover.Trigger
export const PopoverClose = RadixPopover.Close

export function PopoverContent({
  children,
  align = "start",
  side = "bottom",
  className,
}: {
  children: ReactNode
  align?: "start" | "center" | "end"
  /** Which way the panel opens. Set it when the trigger sits at the edge it would open into. */
  side?: "top" | "right" | "bottom" | "left"
  className?: string
})

The content portals out with sideOffset={4} and collisionPadding={8}, and paints at z-[95], the Z_LAYERS.menu tier from src/lib/z-layers.ts, spelled literally because Tailwind reads class names from the source. PopoverGroupLabel is the only addition: a quiet heading over one group of choices, never a control.

A panel that is a layout, not a list

A menu is the right shape for a column of labelled actions; a popover is for a layout such as a grid of previews. Both share the same surface, border, and shadow.

mnemo-web/src/mindmap/chrome/MapStyleMenu.tsx is the example: arrangements, materials, palettes, and backgrounds as tiles.

<Popover>
  <PopoverTrigger asChild>
    <button type="button" title={t("Mindmap", "MapStyle")}>
      {algorithm ? t("Mindmap", LAYOUT_KEY[algorithm]) : t("Mindmap", "MapStyle")}
    </button>
  </PopoverTrigger>

  <PopoverContent align="end" className="w-[262px]">
    <PopoverGroupLabel>{t("Mindmap", "GroupArrangement")}</PopoverGroupLabel>
    <div className="grid grid-cols-3 gap-1 px-1">{/* one Tile per arrangement */}</div>
  </PopoverContent>
</Popover>

One host draws every tooltip

TooltipHost mounts once, last in App.tsx, so its portal is topmost in the body and a hint is never drawn under the overlay whose button raised it; it renders at z-[300], above the dialog tier.

The host listens on the document for pointerover and focusin, then walks up with element.closest("[data-tooltip],[title]"). A plain title is therefore already a Mnemo tooltip, with nothing to opt into.

It removes that title while the hint is up, since Chromium would otherwise show its native tooltip as well. Where title was the only accessible name, the same words go back as aria-label until it is returned.

When a hint needs more than a line

Reach for the component in mnemo-web/src/components/ui/tooltip/Tooltip.tsx when the hint needs a shortcut on a cap, or a side other than above.

export interface TooltipProps {
  /** The line of text. An empty one leaves the child untouched. */
  label: string
  /** A canonical chord ("F", "Primary+Shift+H"), drawn as one cap per key. */
  chord?: string | null
  /** Preferred side. It still flips when there is no room. */
  side?: TooltipSide
  /** A single element that passes props through to a DOM node. */
  children: ReactElement
}

It renders nothing of its own: it clones its child with data-tooltip, adds data-tooltip-chord and data-tooltip-side when given, and clears the child's title. A mindmap toolbar slot in mnemo-web/src/mindmap/chrome/bits.tsx:

<Tooltip label={label} chord={chord}>
  <button type="button" aria-label={label} aria-pressed={active} onClick={onClick}>
    {children}
  </button>
</Tooltip>

Delay, warmth, and what never gets one

The first hint waits SHOW_DELAY, 400 ms of the pointer resting; for WARM_WINDOW, 320 ms after one closes, the next opens instantly, so moving along a toolbar does not wait at every control. A key press, a scroll, or the window losing focus hides it.

Four things raise nothing: a touch pointer, since a tooltip on tap would swallow the tap; a pointer already pressed; focus that is not :focus-visible; and anything inside contenteditable. The last is ProseMirror: moving an attribute on a node the editor owns is a document mutation, so editor chrome marks itself with applyTooltip from src/components/ui/tooltip/apply.ts.