Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Tooltip

A tooltip is a short text that describes a control. It shows when the pointer rests on the control and when the control takes keyboard focus. UniAria shows it in the top layer as a manual popover (popover="manual") and places it next to its trigger as Popover does. The tooltip never takes focus and holds no interactive content. One tooltip shows at a time, and once one has shown, the next opens without waiting. A tooltip can also show the whole of a label cut off with an ellipsis, only while it is cut off.

It follows the WAI-ARIA Tooltip pattern, which the APG still marks as a work in progress: role="tooltip", a reference from the trigger, Escape to dismiss, and focus that stays on the trigger. Overlays covers the top layer and the positioning modes it shares with Popover.

Tooltips on a toolbar
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The tooltips' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  justify-items: center;
  margin: 4rem 1.5rem 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
.row {
  display: flex;
  gap: 0.5rem;
}
p {
  margin: 0;
  color: var(--uni-text-soft-color);
}
</style><div class="row">
  <uni-tooltip relation="label">
    <uni-button>
      <button data-uni="button"><b aria-hidden="true">B</b></button>
    </uni-button>
    <div data-uni="tooltip">
      Bold
      <div data-element="Arrow"></div>
    </div>
  </uni-tooltip>
  <uni-tooltip relation="label">
    <uni-button>
      <button data-uni="button"><i aria-hidden="true">I</i></button>
    </uni-button>
    <div data-uni="tooltip">
      Italic
      <div data-element="Arrow"></div>
    </div>
  </uni-tooltip>
  <uni-tooltip>
    <uni-button>
      <button data-uni="button">Save</button>
    </uni-button>
    <div data-uni="tooltip">
      Saves the file
      <div data-element="Arrow"></div>
    </div>
  </uni-tooltip>
  <uni-tooltip>
    <uni-button disabled focusable-when-disabled>
      <button data-uni="button">Publish</button>
    </uni-button>
    <div data-uni="tooltip">
      Nothing to publish yet
      <div data-element="Arrow"></div>
    </div>
  </uni-tooltip>
</div>
<p>Rest the pointer on a button, or Tab to it.</p>
<script src="/demo/tooltip.js"></script>

Usage

React

import { button } from '@uniaria/core/button';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/react';

export function SaveButton(props: { onSave(): void }) {
  const hint = useBehavior(tooltip, {});
  const save = useBehavior(button, {
    onPress: props.onSave,
    tooltip: hint.instance,
  });
  return (
    <>
      <button {...save.parts.root}>Save</button>
      <div {...hint.parts.root}>
        Saves the file
        <div {...hint.parts.arrow} />
      </div>
    </>
  );
}

Vue

<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/vue';

const emit = defineEmits<{ save: [] }>();

const hint = useBehavior(tooltip);
const save = useBehavior(button, {
  onPress: () => emit('save'),
  tooltip: hint.instance,
});
</script>

<template>
  <button v-bind="save.parts.root">Save</button>
  <div v-bind="hint.parts.root">
    Saves the file
    <div v-bind="hint.parts.arrow" />
  </div>
</template>

HTML

<uni-tooltip>
  <uni-button>
    <button data-uni="button">Save</button>
  </uni-button>
  <div data-uni="tooltip">
    Saves the file
    <div data-element="Arrow"></div>
  </div>
</uni-tooltip>

<script type="module">
  import '@uniaria/elements/button';
  import '@uniaria/elements/tooltip';
</script>

Render the root always, next to its trigger: it stays hidden while closed. In a modal Dialog or a modal Popover, render it inside their element, or the tooltip is inert or hidden from assistive technology.

A tooltip never writes to an element another component owns. Instead, the component it describes links to it. That component puts the tooltip’s id in its focusable part’s aria-describedby (so it is in the server HTML), lets the tooltip listen for hover on its visible part and for focus and presses on its focusable part, and is the tooltip’s anchor.

  • In React and Vue, pass the tooltip’s instance as the component’s tooltip prop, as above. The two can live in different components: pass the instance down.
  • On elements, a UniAria element sitting directly in <uni-tooltip> (with no other UniAria element between them) links itself. One elsewhere names the tooltip by id with a tooltip attribute, looked up in its document or shadow root.
<uni-button tooltip="save-hint">
  <button data-uni="button">Save</button>
</uni-button>

<uni-tooltip id="save-hint">
  <div data-uni="tooltip">Saves the file</div>
</uni-tooltip>

When the element sits inside <uni-tooltip>, define it first: import its entry before @uniaria/elements/tooltip. A <uni-tooltip> that upgrades around an element that isn’t defined yet takes the parts of it that share a name with its own for itself, such as a Popover’s trigger.

Component Hover counts on Focus, presses and the relation on
Button, ToggleButton root root
Checkbox, Switch, Radio wrapper, else root input
TextField, NumberField input input
Dialog, Popover trigger trigger
Select trigger trigger

A tooltip describes one component, or the options of one list (see Option tooltips). A second component that links to it is refused, with a development warning.

On any other element

Anything that isn’t a UniAria component’s part, such as a link or an icon, is the tooltip’s trigger part. It gets the relation and nothing else.

React

const hint = useBehavior(tooltip, {});
return (
  <>
    <a {...hint.parts.trigger} href="/pricing">
      Pricing
    </a>
    <div {...hint.parts.root}>Plans and billing</div>
  </>
);

Vue

<script setup lang="ts">
const hint = useBehavior(tooltip);
</script>

<template>
  <a v-bind="hint.parts.trigger" href="/pricing">Pricing</a>
  <div v-bind="hint.parts.root">Plans and billing</div>
</template>

HTML

<uni-tooltip>
  <a data-uni-part="tooltip-trigger" href="/pricing">Pricing</a>
  <div data-uni="tooltip">Plans and billing</div>
</uni-tooltip>

Keyboard users need the trigger focusable; one that can’t take focus, such as a cut-off label (see Overflow tooltips), gets the tooltip on hover only. Don’t make another component’s part the trigger part: link that component instead.

Label or description

relation decides what the tooltip is to its trigger:

  • 'description', the default: the trigger’s aria-describedby references the tooltip, and screen readers read it after the name: “button, Save, Saves the file”.
  • 'label': the trigger’s aria-labelledby references it, and the tooltip becomes the name. Use it for an icon-only button, whose name would otherwise be repeated as its description: “button, Bold”.
  • 'none': nothing references the tooltip, and its root gets aria-hidden="true" (it keeps role="tooltip"). Use it for text the trigger already holds, as an overflow tooltip shows: screen readers read the whole text on the trigger, and the tooltip would only repeat it.

The relation is there while the tooltip is closed, too: screen readers read a hidden element that aria-describedby or aria-labelledby references, so the text reaches users however they get to the trigger. isDisabled takes it off.

React

const hint = useBehavior(tooltip, { relation: 'label' });
const bold = useBehavior(toggleButton, { tooltip: hint.instance });
return (
  <>
    <button {...bold.parts.root}>
      <BoldIcon aria-hidden />
    </button>
    <div {...hint.parts.root}>Bold</div>
  </>
);

Vue

<script setup lang="ts">
const hint = useBehavior(tooltip, { relation: 'label' });
const bold = useBehavior(toggleButton, { tooltip: hint.instance });
</script>

<template>
  <button v-bind="bold.parts.root">
    <BoldIcon aria-hidden="true" />
  </button>
  <div v-bind="hint.parts.root">Bold</div>
</template>

HTML

<uni-tooltip relation="label">
  <uni-toggle-button>
    <button data-uni="toggle-button"><svg aria-hidden="true">…</svg></button>
  </uni-toggle-button>
  <div data-uni="tooltip">Bold</div>
</uni-tooltip>

Overflow tooltips

A label cut off with an ellipsis can show its whole text in a tooltip. With shouldOpenOnlyWhenTruncated (open-only-when-truncated), the tooltip opens only while the text it describes is cut off, and UniAria writes that text into its text part. Give it relation: 'none' as well: the whole text is in the trigger already.

React

import { button } from '@uniaria/core/button';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/react';

export function ReportButton(props: { name: string; onOpen(): void }) {
  const full = useBehavior(tooltip, {
    shouldOpenOnlyWhenTruncated: true,
    relation: 'none',
  });
  const report = useBehavior(button, {
    onPress: props.onOpen,
    tooltip: full.instance,
  });
  return (
    <>
      <button {...report.parts.root} className="report">
        <span {...report.parts.label}>{props.name}</span>
      </button>
      <div {...full.parts.root}>
        <span {...full.parts.text} />
      </div>
    </>
  );
}

Vue

<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/vue';

defineProps<{ name: string }>();
const emit = defineEmits<{ open: [] }>();

const full = useBehavior(tooltip, {
  shouldOpenOnlyWhenTruncated: true,
  relation: 'none',
});
const report = useBehavior(button, {
  onPress: () => emit('open'),
  tooltip: full.instance,
});
</script>

<template>
  <button v-bind="report.parts.root" class="report">
    <span v-bind="report.parts.label">{{ name }}</span>
  </button>
  <div v-bind="full.parts.root">
    <span v-bind="full.parts.text" />
  </div>
</template>

HTML

<uni-tooltip open-only-when-truncated relation="none">
  <uni-button>
    <button data-uni="button" class="report">
      <span data-element="Label">Save the quarterly report</span>
    </button>
  </uni-button>
  <div data-uni="tooltip"><span data-element="Text"></span></div>
</uni-tooltip>

Your CSS cuts the text; UniAria only measures it. An inline element never clips, so make the cut element a block:

.report {
  inline-size: 10rem;
}
.report [data-element='Label'] {
  display: block;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
[data-uni='tooltip'] [data-element='Text'] {
  white-space: pre-line;
}
  • When it measures. When hover starts, before the delay: text that fits starts no delay and doesn’t warm the page up. Then again as it opens, after the delay, on focus or from open(), since the layout may have changed meanwhile. Nothing measures on mount and nothing watches for resizes, so a page with thousands of these costs nothing until the pointer or focus arrives. An open tooltip doesn’t close when its text grows to fit; it closes as any tooltip does.
  • What counts as cut off. An element that clips what overflows it (its overflow isn’t visible) and whose content is wider than its box, even by a fraction of a pixel (tested at 67% to 150% zoom). Or one whose text wraps and is taller than its box by more than a pixel, as with a line clamp.
  • What it shows. While open, the text part holds the whole text of each element that is cut off, not what the ellipsis left, without what CSS hides. Each element’s text is a line of its own, separated by \n, which white-space: pre-line shows. Without shouldOpenOnlyWhenTruncated, it holds the text of all of them, cut off or not. Leave the part out to write the content yourself.
  • open() does nothing while the text fits.
  • Controlled, it asks to open (openchange) only while the text is cut off. An isOpen you set shows it anyway, as defaultOpen does, with the cut-off text, or all of it when nothing is cut off. A tooltip open from the start fills its text part once the component it describes has linked to it.
  • Disabled controls. An overflow tooltip never makes a disabled control focusable: isFocusableWhenDisabled stays yours to set.

What it measures and shows depends on what it describes:

It describes The text it measures and shows
the trigger part The trigger.
Button, ToggleButton The label and description parts while rendered, else the root.
Popover’s or Dialog’s trigger triggerLabel and triggerDescription while rendered, else the trigger.
Select The value part while rendered, else the trigger.
Checkbox, Switch, Radio The label and description parts while rendered.
TextField, NumberField None: an input scrolls its text instead of cutting it, so an overflow tooltip never opens.
an option, through its list’s option tooltip optionLabel and optionDescription while rendered, else the option.

Option tooltips

Listbox, Select and Combobox take one tooltip for all their options, through their optionTooltip link: in React and Vue the tooltip’s instance, on the element an option-tooltip attribute naming the <uni-tooltip> by id. On the element, only the attribute links it: a list inside a <uni-tooltip> doesn’t take that tooltip for its options.

The list drives the tooltip. The option under the pointer counts as a hovered trigger, and the option with visible focus, or Combobox’s visible highlight, as a focused one. The tooltip is placed against that option and shows its text.

  • Moving on. While it is open, moving to another option moves it there at once if that option’s text is cut off, and closes it otherwise.
  • Presses and typing. A press on an option closes it until the pointer leaves that option. Typing, for typeahead or in Combobox’s input, closes it until a navigation key (the arrows, Home, End, PageUp or PageDown) moves focus or the highlight.
  • No relation. The list references the tooltip from no option, since an option’s name comes from its own text. Give the tooltip relation: 'none'; development builds warn otherwise. Without shouldOpenOnlyWhenTruncated, it opens on every option.
  • Where to render it. Beside the list, never inside an option. For Select and Combobox, inside their popover, after the list.
  • Virtualized lists. An option that leaves the rendered set closes it.

Disabled controls

A tooltip that says why a control is disabled is the common case, so the tooltip ignores its trigger’s disabled state. Hover reaches natively disabled controls in every engine, but the keyboard can’t focus them: give the control isFocusableWhenDisabled (Button, ToggleButton, Checkbox, Switch and Radio have it), as the Publish button in the demo does. Screen readers then read “button, Publish, Nothing to publish yet, disabled”.

The tooltip’s own isDisabled is different: the tooltip has nothing to say. For a label that may or may not be cut off, use an overflow tooltip instead.

Focus only

trigger: 'focus' (trigger="focus") opens the tooltip on keyboard focus only; hover neither opens it nor keeps it open.

Delays and warm-up

Hover opens a tooltip after delay (250 ms), and leaving the trigger and the tooltip closes it after closeDelay (500 ms); coming back in time keeps it open. Once a tooltip has shown, the page is warm while one is open and for 500 ms or closeDelay, whichever is longer, after the last one started closing. While warm, hover opens a tooltip at once and marks it with data-instant, so CSS can skip its entrance. Moving to another trigger while waiting starts the wait again for that one. Focus always opens a tooltip at once and makes the page warm.

A tooltip that opens closes the open one at once, without its closing phase. Every copy of UniAria on the page shares this, so tooltips from different bundles take turns too.

Positioning

A tooltip is placed like a popover, against the anchor part, else the linked component, else the trigger part. positionArea defaults to 'top', positionTryFallbacks to 'flip-block, flip-inline', and the gap is the root’s margin. In CSS mode, set with configure({ positioning: 'css' }), give the root inset: auto, a position-area and fallbacks in your CSS. See Popover for both modes.

React

const hint = useBehavior(tooltip, { positionArea: 'right' });

Vue

<script setup lang="ts">
const hint = useBehavior(tooltip, { positionArea: 'right' });
</script>

HTML

<uni-tooltip position-area="right">…</uni-tooltip>

Controlled

isOpen and onOpenChange work as on other overlays: every close only reports openchange, and the tooltip stays open until you set isOpen to false. A controlled tooltip kept open stays open beside the next one that shows.

Anatomy

Part Element Notes
trigger a focusable element; usually button Optional. For an element that isn’t a UniAria component’s part; a linked component takes its place. UniAria writes aria-describedby (aria-labelledby with relation: 'label', nothing with 'none') while the tooltip is enabled, and no state attributes.
anchor any Optional. The tooltip is placed against it instead of the trigger.
root any; usually div The text. Hidden while closed. UniAria writes its id (unless it has one), popover="manual", role="tooltip", data-placement and its position, and aria-hidden="true" with relation: 'none'. Text only: development builds warn when it holds a focusable element.
arrow any Optional. UniAria writes aria-hidden="true" and its offset along the edge; your CSS puts it on the side data-placement names.
text any, inside the root; usually span Optional. Render it empty: while open, UniAria writes into it the text of what the tooltip describes, one element’s text per line (see Overflow tooltips). Leave it out to write the content yourself.

A part keeps an id you give it, and everything that refers to the part follows it; UniAria writes an id only on a part that has none (see Ids).

The root gets no tabindex, and the trigger no aria-expanded, aria-haspopup or aria-controls: a tooltip isn’t a popup the user opens.

Props

Prop Attribute Type Default Description
isOpen — (property: setting it makes it controlled) boolean — The open state, controlled. Pair it with onOpenChange.
defaultOpen default-open boolean false The starting state, uncontrolled.
isDisabled disabled boolean false Never opens, closes when open, and takes the relation off the trigger. For a tooltip with nothing to say.
delay delay number 250 Milliseconds the pointer rests on the trigger before it opens, while the page isn’t warm.
closeDelay close-delay number 500 Milliseconds after the pointer leaves the trigger and the tooltip before it closes.
trigger trigger 'hover' | 'focus' 'hover' 'focus' opens it on keyboard focus only.
relation relation 'description' | 'label' | 'none' 'description' 'label' makes the tooltip its trigger’s name, for an icon-only button. 'none' references it from nothing and hides it from assistive technology.
shouldOpenOnlyWhenTruncated open-only-when-truncated boolean false Opens only while the text it describes is cut off, for a tooltip that shows text the trigger already holds. See Overflow tooltips.
positionArea position-area PositionArea 'top' A CSS position-area on one side of the anchor. Script mode only.
positionTryFallbacks position-try-fallbacks string 'flip-block, flip-inline' A CSS position-try-fallbacks. Script mode only.

On the element, the trigger property is this prop; the trigger part is the element marked data-uni-part="tooltip-trigger".

Events

Event Callback prop Detail When
openchange onOpenChange boolean Hover, focus, Escape, a press, scrolling or another tooltip opening asks to open or close it. Never when you set isOpen.

There is no dismiss event: a tooltip closes for too many ordinary reasons, like the pointer moving on, for a reason to help.

Actions

Action What it does
open() Shows the tooltip at once, without its delay. Does nothing while disabled, or with shouldOpenOnlyWhenTruncated while the text fits. It stays open until close() or one of its usual closes: Escape, a press on the trigger, or hover or focus on the trigger ending.
close() Closes it. Unlike Escape, it doesn’t keep the tooltip closed: the next hover opens it as usual.

In React and Vue they are on the handle (hint.actions.open()); on the custom element they are methods (document.querySelector('uni-tooltip').open()). While isOpen is controlled, each only reports openChange and leaves the change to you.

State

Field Type Meaning
isOpen boolean The tooltip is open.
isEntering boolean The first frames after opening, for enter transitions.
isExiting boolean Closed, but still shown until its exit animations end.
isInstant boolean Open, and opened without its delay because the page was warm.
placement 'top' | 'bottom' | 'left' | 'right' | undefined The side it was placed on, after fallbacks; undefined while it isn’t shown.
const placement = useBehaviorState(hint, (state) => state.placement);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="tooltip" root Always.
data-uni-part="tooltip-trigger", data-uni-part="tooltip-anchor" trigger, anchor Always.
data-element every other part Always.
data-open root, arrow, text The tooltip is open.
data-entering root, arrow, text The first frames after opening.
data-exiting root, arrow, text It is closing, until its exit animations end. Skipped when another tooltip replaces it.
data-instant root, arrow, text It opened without its delay: skip the entrance.
data-placement root, arrow While shown: the side it was placed on, top, bottom, left or right.
aria-hidden="true" root relation: 'none'.

The root also gets --anchor-width, --available-width and --available-height, as a Popover’s does, and the same inline position styles, put back when it closes. Keep pointer events on the root: the pointer can move onto the tooltip and keep it open.

[data-uni='tooltip'] {
  margin: 8px; /* the gap, and the room from the viewport's edges */
  padding: 0.25rem 0.5rem;
  border: 0;
  border-radius: 0.375rem;
  background: CanvasText;
  color: Canvas;
  overflow: visible;
  transition:
    opacity 150ms,
    translate 150ms;
}
[data-uni='tooltip']:is([data-entering], [data-exiting]) {
  opacity: 0;
  translate: 0 2px;
}
[data-uni='tooltip'][data-instant] {
  transition: none;
}

The arrow works as a popover’s: position it on the side data-placement names and rotate a pseudo-element inside it, since UniAria measures the arrow part itself.

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout. The tooltip is the default fill, dark in every scheme, with 12/18 text, at most 20rem wide, 8 pixels from its trigger, with an arrow in the fill. Opening, it fades in and moves 8 pixels away from the trigger as it grows from 80%, in 80 milliseconds, and it appears at once when it opens without its delay. The text part keeps its line breaks, so a label and its description each take a line. The trigger is the control it describes, so it keeps its own look.

Keyboard

Key Behavior
Tab, Shift+Tab Onto the trigger, opens it at once. Off the trigger, closes it, unless the pointer is over the trigger or the tooltip.
Escape Anywhere, while it is open, closes it and prevents the key’s default, so a Popover or Dialog around it stays open. A second Escape closes that. Ignored while composing text with an IME.
Any other key but a lone modifier On the trigger, closes it, as a press does.

After Escape or a press, the tooltip stays closed until the pointer leaves the trigger and the tooltip and comes back, or focus leaves and comes back.

Accessibility

  • Focus never moves. The tooltip can’t take focus, and focus stays on the trigger the whole time. Interactive content belongs in a non-modal Popover.
  • Which focus opens it. Visible focus only: from the keyboard, or a screen reader moving focus. Not focus from a pointer press, not focus an overlay gives back as it closes (a Popover closed with Escape doesn’t pop up its trigger’s tooltip), and not focus coming back with the window.
  • Hover and focus each keep it open, as the APG describes: a keyboard user’s tooltip doesn’t close because the mouse crossed the trigger.
  • Hoverable and dismissable (WCAG 1.4.13): the pointer can move onto the tooltip without it closing, closeDelay covers the gap, and Escape closes it without moving the pointer or focus.
  • Touch never opens it, and neither do the mouse events a tap emulates. A description that matters on touch belongs in the page or in a Popover.
  • Spoken output. “button, Save, Saves the file” on the trigger, whether the tooltip is open or not; “tooltip, Saves the file” when browsing past the open tooltip; “button, Bold” with relation: 'label'. A cut-off label with its overflow tooltip open and relation: 'none' reads “button, Save the quarterly report”, the whole text, and nothing is read for the tooltip.
  • Scrolling. The tooltip follows its trigger and closes once the trigger is scrolled out of view.

Compared with other libraries

  • The component links to the tooltip, as Cube UI Kit’s tooltip props do. React Aria’s TooltipTrigger wraps a focusable child and reaches it through context.
  • Delays of 250 and 500 ms with warm-up, where React Aria waits 1500 ms.
  • The relation stays while closed. React Aria, Zag and Cube UI Kit add aria-describedby only while the tooltip is open.
  • relation: 'label' for icon-only buttons, which React Aria doesn’t offer.
  • Escape is prevented, not stopped, so the first Escape closes the tooltip and the next one the popover around it. React Aria, Zag and Cube UI Kit stop its propagation.
  • Hover and focus each keep it open, and it is always hoverable. React Aria closes it when either ends; Cube UI Kit’s tooltips ignore the pointer unless isMaterial.
  • Disabled triggers keep their tooltips. React Aria shows none.
  • Overflow tooltips measure only when they would open, and stay out of the accessibility tree. Cube UI Kit measures every item as it mounts, and reads the text again as a description; React Aria has no overflow tooltip.