Skip to contentAsheeUIAsheeUI

Tooltip

A short description of the control it points at, and never a place to put anything.

A tooltip says the thing its control has no room to say: the unit behind a number, the shortcut behind an icon, the reason a button is unavailable. It opens while the reader rests on the trigger or has the trigger focused, and it goes away when they move on, so it behaves like a note attached to the control rather than a panel of its own.

That is the distinction worth keeping: a tooltip describes, it does not contain. It never takes focus and cannot be reached by a pointer, so everything inside it has to be something the reader could have heard or seen anyway.

Usage

import { Button, Tooltip } from "@asheeui/web";

<Tooltip content="Kept for seven years, then deleted">
  <Button variant="ghost">Retention</Button>
</Tooltip>

content is the sentence the hint says; the single child is the trigger, and it keeps its own behaviour, because a tooltip attaches a description rather than a handler. The child has to be a single focusable element, a Button, a link, a field, because the hint opens on its focus as well as its hover, and a string is rejected by the type: a bare string is neither hovered as a control nor reached with a keyboard. An untyped JavaScript string still renders, wrapped in a focusable span, so a hint handed one is not unreachable; the type is what stops it in TypeScript.

content counts as empty only when it is null, undefined, "" or a boolean. Then the trigger renders on its own with nothing attached, and isDisabled does the same: it stops the hint appearing, not the control working. The button under it is still pressable, still focusable and still announces itself, only the description is missing. content={0} is content, though, and renders, because a remaining count of zero is something to say.

The trigger a tooltip suits best is the icon-only control, which is also the one that still needs its own accessible name:

<Tooltip content="Refresh the totals">
  <Button icon aria-label="Refresh totals">
    ↻
  </Button>
</Tooltip>

Placement

placement names a side, top unless the instance or the configuration says otherwise, and the twelve values are the four sides with a plain, a -start and an -end alignment each. The side is a preference rather than a promise: the hint flips when the side it asked for has no room left in the viewport, and shifts along that side to stay on screen, so a hint on a control near the top of a page still reads in full.

offset is the distance from the trigger, 8 points, and showArrow draws a pointer back at it, which is worth turning on wherever the hint could plausibly belong to a neighbour. By default the hint renders in place, inside the trigger's stacking context, so an ancestor's overflow: hidden can clip it. portal renders it at the document body instead, on a layer derived from the trigger's own stacking context, and zIndex changes the delta the hint adds to that context. The default is false on purpose: an ordinary hint belongs beside the control it describes, and a portal is for the one inside a scrolling table or a clipped panel.

Timing

A hint waits delay before it appears, 200ms, because one that appears the instant the pointer is over something turns a row of icons into a light show as the reader crosses it. The wait is the difference between travelling across a control and stopping on it.

A number states both halves of the wait. delay={{ open, close }} states them apart: a longer open asks the reader to stop before the hint appears, and a close keeps it up for a moment after the pointer has left, so moving between two adjacent triggers does not blink it out and back in. Focus is not delayed at all: a keyboard reader who has tabbed onto a control has already committed to it, and the hint is the control's description, so it opens with it.

On a phone there is no pointer to rest anywhere, so @asheeui/native reads the same delay as the length of a long press: the hint appears after the press has been held that long, and stays while it is held. The close half is honoured there too, the hint stays for it after the press ends, a number states both halves as it does here, and an object with no close hides the hint the moment the thumb lifts. The platform's reference has the rest of the differences; see React Native.

Props

The table is a cascade rather than a list. size (md), placement (top), delay (200), offset (8), showArrow (false), portal (false) and zIndex (30, the tooltip layer) are registered by the component, so stating them under components.tooltip gives every hint in a product the same habits. variant and color are deliberately not registered: they resolve from the application's defaultVariant and defaultColor, which is why an unconfigured application gets a faded hint in the primary colour. radius is not registered either, it follows defaultRadius, so it is xs until the application says otherwise, and className is the one option with no default to resolve. portal and zIndex are web options: the platform has no document to portal into.

So a page with no configuration at all gets a md hint in the primary colour, placed above its trigger, 8 points away, after 200ms, with an xs radius, no arrow, rendered in place. Two lines of configuration give the same hint everywhere without restating it per instance.

Accessibility

  • Focus opens the hint as reliably as hover does, with no delay, so a keyboard reader is not the one reader who never sees the description.
  • It is a description rather than a label: the hint is exposed as role="tooltip" and its id is added to the trigger's aria-describedby, so a description the trigger already had, help text beside a field, say, survives while the hint is open. A screen reader reads the hint after the control's own name: the control is what the reader lands on, the hint is what they hear about it.
  • It never takes focus. The hint closes as soon as the trigger loses it, so nothing inside can be tabbed to, and the panel ignores the pointer, so moving towards it dismisses it. That is why a control, a link or a field must never go in content: it could not be used. Interactive content belongs in a menu, a Dropdown, or in a layer such as modal.
  • The control must make sense without it. An icon-only button still needs aria-label, and a hint that carries the only statement of what a control does is missing for any reader who never hovers, which, on a touch screen, is most of them. Escape closes the hint and does nothing to the control.

The difference between a hint and a layer is set out once in Focus and dismissal; a tooltip is the overlay that describes its trigger and asks the page for nothing.