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.
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>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
instanceas the component’stooltipprop, 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 atooltipattribute, 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.
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.
relation decides what the tooltip is to its trigger:
'description', the default: the trigger’saria-describedbyreferences the tooltip, and screen readers read it after the name: “button, Save, Saves the file”.'label': the trigger’saria-labelledbyreferences 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 getsaria-hidden="true"(it keepsrole="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>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
overflowisn’tvisible) 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
textpart 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, whichwhite-space: pre-lineshows. WithoutshouldOpenOnlyWhenTruncated, 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. AnisOpenyou set shows it anyway, asdefaultOpendoes, with the cut-off text, or all of it when nothing is cut off. A tooltip open from the start fills itstextpart once the component it describes has linked to it. - Disabled controls. An overflow tooltip never makes a disabled control
focusable:
isFocusableWhenDisabledstays 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. |
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. WithoutshouldOpenOnlyWhenTruncated, 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.
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.
trigger: 'focus' (trigger="focus") opens the tooltip on keyboard focus
only; hover neither opens it nor keeps it open.
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.
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>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.
| 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.
| 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".
| 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.
| 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.
| 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.
| 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.
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.
| 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.
- 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,
closeDelaycovers 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 andrelation: '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.
- The component links to the tooltip, as Cube UI Kit’s
tooltipprops 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-describedbyonly 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.