Stems UI
Menu

Tooltip

A popup that shows a short hint when an element is hovered or focused.

Last change: none

Codeapp/demos/public/tooltip.tsx
import { clientEntry, type Handle, type RemixNode } from 'remix/component'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@stemsui/registry/ui/tooltip.tsx'

function Icon(handle: Handle<{ children: RemixNode }>) {
  return () => (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      {handle.props.children}
    </svg>
  )
}

const tools = [
  {
    label: 'Bold',
    shortcut: '⌘B',
    icon: <path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" />,
  },
  {
    label: 'Italic',
    shortcut: '⌘I',
    icon: (
      <>
        <line x1="19" x2="10" y1="4" y2="4" />
        <line x1="14" x2="5" y1="20" y2="20" />
        <line x1="15" x2="9" y1="4" y2="20" />
      </>
    ),
  },
  {
    label: 'Underline',
    shortcut: '⌘U',
    icon: (
      <>
        <path d="M6 4v6a6 6 0 0 0 12 0V4" />
        <line x1="4" x2="20" y1="20" y2="20" />
      </>
    ),
  },
  {
    label: 'Insert link',
    shortcut: '⌘K',
    icon: (
      <>
        <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
        <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
      </>
    ),
  },
] as const

const sides = ['top', 'right', 'bottom', 'left'] as const

export const TooltipDemo = clientEntry(import.meta.url, function TooltipDemo(handle: Handle) {
  let lastChange = 'none'

  return () => (
    <div class="flex flex-col items-center gap-10">
      {/* One provider: once a tooltip is open, the neighbors open instantly. */}
      <TooltipProvider delay={600}>
        <div
          class="flex items-center gap-1 rounded-lg border p-1"
          role="toolbar"
          aria-label="Formatting"
        >
          {tools.map((tool) => (
            <Tooltip
              onOpenChange={(open, details) => {
                lastChange = `${tool.label} ${open ? 'opened' : 'closed'} (${details.reason})`
                void handle.update()
              }}
            >
              <TooltipTrigger
                aria-label={tool.label}
                class={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
                data-testid={`tool-${tool.label.toLowerCase().replace(' ', '-')}`}
              >
                <Icon>{tool.icon}</Icon>
              </TooltipTrigger>
              <TooltipContent>
                {tool.label}
                <kbd
                  data-slot="kbd"
                  class="inline-flex h-5 items-center rounded-sm bg-background/20 px-1 font-sans text-[0.7rem] text-background"
                >
                  {tool.shortcut}
                </kbd>
              </TooltipContent>
            </Tooltip>
          ))}
        </div>
      </TooltipProvider>

      <TooltipProvider>
        <div class="flex flex-wrap justify-center gap-2">
          {sides.map((side) => (
            <Tooltip>
              <TooltipTrigger
                class={buttonVariants({ variant: 'outline', size: 'sm' })}
                data-testid={`side-${side}`}
              >
                {side}
              </TooltipTrigger>
              <TooltipContent side={side}>Tooltip on the {side}</TooltipContent>
            </Tooltip>
          ))}
        </div>
      </TooltipProvider>

      <p class="text-sm text-muted-foreground" data-testid="tooltip-result">
        Last change: <span class="font-medium text-foreground">{lastChange}</span>
      </p>
    </div>
  )
})

Installation

npx shadcn add @stemsui/tooltip

Requires the one-time setup. Behavior: stems-ui/tooltip.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

stems-ui/tooltip

tooltip shows a short visual hint when an element is hovered or focused with the keyboard. The popup opens in the top layer, never takes focus, and closes on Escape, press, blur, or pointer leave. App code owns the markup and visual styles.

Base UI equivalent: Tooltip.

Usage

import * as tooltip from 'stems-ui/tooltip'

function Toolbar() {
  return () => (
    <tooltip.Provider>
      <tooltip.Context>
        <button aria-label="Bold" mix={tooltip.trigger()}>
          <BoldIcon />
        </button>
        <div
          class="rounded bg-black px-2 py-1 text-xs text-white transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0 data-instant:transition-none"
          mix={tooltip.popup({ sideOffset: 6 })}
        >
          <span class="size-2 rotate-45 bg-black data-[side=top]:-bottom-1" mix={tooltip.arrow()} />
          Bold
        </div>
      </tooltip.Context>
      {/* more tooltip.Context blocks share the provider's delays */}
    </tooltip.Provider>
  )
}

Tooltips are visual hints for sighted mouse and keyboard users. They don't open on touch. Give the trigger an accessible name (aria-label) that matches the tooltip text, and use a popover with openOnHover for content people must be able to reach (infotips).

Control the state with open and onOpenChange(open, details), or let the context own it with defaultOpen. Listen for bubbling changes on the trigger or an ancestor:

<div mix={tooltip.onTooltipChange((event) => console.log(event.open, event.reason))}>…</div>
Differences from Base UI
  • No Portal, no Positioner element. The popup is shown in the top layer with popover="manual" + showPopover(). Base UI's Positioner props are options of popup(), and the positioner's data attributes and CSS variables are set on the popup element.
  • role="tooltip" is set on the popup. Like Base UI, the trigger gets no aria-describedby: the trigger's own label is the accessible name, so the hint isn't announced twice.
  • The trigger gets no type, role, or aria-*: it keeps its own semantics (it may be a submit button, a link, …). disabled doesn't disable the element itself.
  • The Viewport part, actionsRef, --positioner-* / --popup-* variables, data-anchor-hidden, collisionAvoidance, collisionBoundary, sticky, positionMethod, and disableAnchorTracking are not supported. Several triggers inside one Context share a tooltip (it anchors to the one that opened it).
  • The popup element always stays in the DOM (closed popups get hidden).

stems-ui/tooltip

  • Provider: shares hover delays between the tooltips inside it (Base UI's Tooltip.Provider). Once one tooltip is open, or for timeout ms after it closed, hovering another trigger opens its tooltip with no delay and closes the previous one at once (reason 'none'). Both get data-instant="delay" so they can skip their animations.
    • delay: number. Hover-open delay for triggers that don't set one.
    • closeDelay: number. Hover-close delay for triggers that don't set one.
    • timeout: number. Defaults to 400.
  • Context: provider that owns one tooltip's open state (Base UI's Tooltip.Root). Read it in components with handle.context.get(tooltip.Context) (open, openReason, instant, setOpen(open, { reason }), activeTrigger, side, align, …).
    • open: boolean. Controlled open state.
    • defaultOpen: boolean. Initial uncontrolled state. Defaults to false.
    • onOpenChange(open, details): called before the state changes. details has reason, event, trigger, and cancel() (keeps the current state).
    • onOpenChangeComplete(open): called after the enter or exit animation finished.
    • disabled: boolean. Triggers don't open the tooltip; an open tooltip closes with reason 'disabled'.
    • disableHoverablePopup: boolean. Close as soon as the pointer leaves the trigger, even toward the popup. Defaults to false (the popup can be hovered; moving toward it keeps it open).
    • trackCursorAxis: 'none' | 'x' | 'y' | 'both'. Position the popup at the cursor along that axis while the pointer is over the trigger. 'both' also disables the hoverable popup. Defaults to 'none'.
  • trigger(options?): the element the tooltip describes.
    • delay: number. Hover-open delay in ms. Defaults to the provider's delay, else 600; 0 while the provider's group is active.
    • closeDelay: number. Hover-close delay in ms. Defaults to the provider's closeDelay, else 0.
    • closeOnClick: boolean. Close when the trigger is pressed. Defaults to true.
    • disabled: boolean. Don't open from this trigger (renders data-trigger-disabled).
  • popup(options?): the tooltip (Base UI's Positioner + Popup). Sets id, role="tooltip", popover="manual", tabIndex={-1}, hidden while closed, and the attributes and variables below. Never moves focus.
    • side: 'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end'. Defaults to 'top'. Flips to the opposite side when it doesn't fit.
    • align: 'start' | 'center' | 'end'. Defaults to 'center'.
    • sideOffset, alignOffset: number | (data) => number. Default 0.
    • collisionPadding: number | { top, right, bottom, left }. Defaults to 5.
    • arrowPadding: number. Defaults to 5.
    • anchor: HTMLElement | { x, y, width?, height? } | () => …. Position against something other than the trigger (overrides trackCursorAxis).
  • arrow(): an element inside the popup that points at the anchor. Gets inline position: absolute plus left (top/bottom sides) or top (left/right sides). The UA popover style overflow: auto clips an arrow placed outside the popup box: add overflow: visible.
  • onTooltipChange(handler): listener mixin for TooltipChangeEvent (stems:tooltip-change, bubbles from the active trigger, has open and reason).
Change reasons

'trigger-hover', 'trigger-focus' (keyboard focus and blur), 'trigger-press', 'outside-press', 'escape-key', 'disabled', and 'none' (closed because a sibling in the provider opened, or state changed through the controlled open prop).

Detached triggers

Base UI's createHandle(): triggers can live anywhere in the document, outside the Context, linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with different data.

import * as tooltip from 'stems-ui/tooltip'

let people = tooltip.createHandle<{ name: string }>()

// Anywhere, e.g. in a table row:
<button mix={tooltip.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={tooltip.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>

// Elsewhere:
<tooltip.Context handle={people}>
  {({ payload }) => <div mix={tooltip.popup()}>{payload?.name}</div>}
</tooltip.Context>
  • createHandle<Payload>() returns the handle. Pass it to the Context (handle prop, read once) and to each detached trigger({ handle, payload }) (read on the trigger's first render). Payload types payload in the Context's children function.
  • Detached triggers register with the handle, so they behave like triggers inside the Context: data-popup-open follows the tooltip, hover and focus open it from any trigger, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it).
  • The active trigger's payload is { payload } in a children render function (re-run each time the Context renders) and payload on the context value (handle.context.get(tooltip.Context).payload). It's undefined until a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElement rejects function children). payload also works on triggers inside the Context.
  • Handle methods: isOpen, open(triggerId?) (opens, or moves an open popup, to the trigger with that id; reason 'imperative-action'), close(). They act on the mounted Context and are ignored while none is mounted. A remounted Context starts fresh.
  • Context props triggerId (controlled, pair with open; store details.trigger?.id from onOpenChange) and defaultTriggerId pick the active trigger by element id.
  • Detached triggers aren't re-rendered by the Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and without aria-controls; it gets aria-controls once the Context mounts in the browser.

Behavior notes

  • Hover: opens after delay (mouse and pen only; touch never opens a tooltip). Leaving the trigger closes it after closeDelay, unless the pointer moves onto the popup (or toward it, via hover-aim) and the popup is hoverable.
  • Keyboard focus: opens immediately when the trigger matches :focus-visible (Tab), not when a pointer press focused it. Blur closes it, unless focus moved into the popup or another trigger of the same tooltip.
  • Press: closes the tooltip (closeOnClick) and cancels a pending hover open. Focus and pointer stay on the trigger without reopening it until the pointer leaves or the trigger blurs.
  • Escape closes the tooltip through the shared dismiss-layer stack and stops the event, so a tooltip inside a popover or dialog closes without closing its container. An outside press closes it too.
  • Focus never moves into or out of the tooltip.
  • data-instant: 'focus' when opened by keyboard focus, 'dismiss' after closing with Escape or a press, 'delay' while moving between tooltips of a provider group. Use it to skip transitions (data-instant:transition-none).
  • Closed popups get hidden in server HTML. Classes that set display (e.g. inline-flex) override hidden unless your CSS hides [hidden] with !important (Tailwind's preflight does).

Styling hooks

PartAttributeValues / notes
triggerdata-popup-openpresent while open (on the trigger that opened it)
triggerdata-trigger-disabledpresent when the trigger or the tooltip is disabled
popupdata-openpresent when open
popupdata-closedpresent when closed (also while animating out)
popupdata-sidefinal side: top | bottom | left | right | inline-*
popupdata-alignstart | center | end
popupdata-instantdelay | dismiss | focus: skip the animation
popupdata-starting-stylepresent on the first frame of opening
popupdata-ending-stylepresent while animating out
popuphiddenpresent when closed and not animating
arrowdata-sidesame as the popup
arrowdata-alignsame as the popup
arrowdata-instantsame as the popup
arrowdata-uncenteredpresent when the arrow can't point at the anchor's center
arrowdata-openpresent when open
arrowdata-closedpresent when closed
PartCSS variableValue
popup--anchor-widththe anchor's width in px
popup--anchor-heightthe anchor's height in px
popup--available-widthspace available on the final side, in px
popup--available-heightspace available on the final side, in px
popup--transform-originthe anchor point relative to the popup (for scale/zoom-in)