Tooltip
Names a control the moment someone hovers it or tabs onto it.
Tooltip puts a short label beside a control once the pointer has rested on it, or straight away when someone tabs onto it. Use it for the name of an icon button or a keyboard shortcut, never for anything a reader has to act on: the popup is not focusable and disappears again the moment the pointer moves on.
The example switches the portal off, so the popup renders in place and scrolls with the frame around it rather than with the page.
<template>
<Tooltip.Group :options="{ timeout: 400 }">
<div class="flex flex-wrap items-center gap-2">
<Tooltip.Root
v-for="action in actions"
:key="action.label"
:options="options"
>
<Tooltip.Trigger
:aria-label="action.label"
class="border-surface rounded-component-lg text-primary-solid hover:bg-primary-subtle active:bg-primary-subtle-active focus-visible:focus-ring inline-flex size-12 items-center justify-center border-2 outline-4 outline-transparent transition-all duration-100 ease-linear select-none"
>
<svg class="size-5" viewBox="0 0 20 20" aria-hidden="true">
<path
:d="action.path"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Tooltip.Trigger>
<Tooltip.Content
class="bg-primary-solid text-primary-on-solid type-component-sm rounded-component-md px-2.5 py-1.5 whitespace-nowrap select-none"
:side-offset="8"
>
{{ action.label }}
<Tooltip.Arrow
class="bg-primary-solid size-2 rotate-45 data-[side=bottom]:bottom-full data-[side=bottom]:-mb-1 data-[side=left]:left-full data-[side=left]:-ml-1 data-[side=right]:right-full data-[side=right]:-mr-1 data-[side=top]:top-full data-[side=top]:-mt-1"
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Tooltip.Group>
</template>
<script setup lang="ts">
import { Tooltip } from '@maas/mirror/vue'
const options = {
delay: 500,
portal: { disabled: true },
}
const actions = [
{ label: 'Undo', path: 'M7 5 3 9l4 4M3 9h8a6 6 0 0 1 0 12H8' },
{ label: 'Redo', path: 'M13 5l4 4-4 4M17 9H9a6 6 0 0 0 0 12h3' },
{ label: 'Duplicate', path: 'M7 7V4h9v9h-3M4 7h9v9H4z' },
]
</script>
Usage guidelines
- A tooltip is a hint rather than a name, so give the trigger an
aria-labelor visible text of its own; a reader who never hovers still has to know what the control does. - Nothing in the popup can be reached by keyboard or by touch, so links, buttons and anything else a reader has to act on belong somewhere else.
Tooltip.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the trigger sits inside anything withoverflow: hidden.- Wrap a row of triggers in one
Tooltip.Groupand the second tooltip opens the moment the pointer arrives, so scanning a row of icon buttons does not wait out the delay again at every one.
Anatomy
Assemble the parts. Tooltip.Group is optional and only matters where several
tooltips sit together. Tooltip.Content is one part over three boxes: the
teleport, the positioned element and the popup. Everything you put inside it
renders in the popup.
| Name | Required | Description |
|---|---|---|
| Tooltip.Group | Optional. Keeps the tooltips it wraps warm, so the next one opens at once. | |
| Tooltip.Root | true | Renders its children. Owns the open state. |
| Tooltip.Trigger | true | Renders a <button>. Opens on hover and on focus. |
| Tooltip.Content | true | Renders the teleport, the floating box and the <div role="tooltip">. |
| Tooltip.Arrow | Renders a <div> pointing back at the trigger. |
<script setup lang="ts">
import { Tooltip } from '@maas/mirror/vue'
</script>
<template>
<Tooltip.Group>
<Tooltip.Root>
<Tooltip.Trigger aria-label="Duplicate" />
<Tooltip.Content>
Duplicate
<Tooltip.Arrow />
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Group>
</template>
Examples
Wrapping a control you already have
The trigger has to stay the element the reader focuses, so pass asChild and
the button you were going to render anyway keeps its own type, its own handler
and its place in the tab order.
<template>
<Tooltip.Trigger as-child>
<Button aria-label="Delete" @click="remove">
<TrashIcon />
</Button>
</Tooltip.Trigger>
</template>
Sharing the timings across a group
Every timing belongs to Tooltip.Root. To give a row of tooltips the same ones,
declare the options once and bind that object to each root.
<script setup lang="ts">
const tooltipOptions = {
delay: 400,
closeDelay: 100,
}
</script>
<template>
<Tooltip.Root
v-for="action in actions"
:key="action.label"
:options="tooltipOptions"
>
<Tooltip.Trigger :aria-label="action.label" />
</Tooltip.Root>
</template>
Tooltip.Group owns one timing of its own. It stays warm for timeout
milliseconds after its last tooltip closed, and a tooltip opening inside that
window skips its delay entirely and says so with data-instant="delay".
<template>
<Tooltip.Group :options="{ timeout: 600 }">
<Tooltip.Root
v-for="action in actions"
:key="action.label"
:options="tooltipOptions"
>
<Tooltip.Trigger :aria-label="action.label" />
</Tooltip.Root>
</Tooltip.Group>
</template>
Give one root an object of its own to take it out of the shared timings.
<template>
<Tooltip.Root :options="{ delay: 1200 }" />
</template>
Positioning
Tooltip.Content positions itself against the trigger, steered by the
floating keys on the root’s options; the options table below lists every
one of them. The resolved placement comes back
as data-side and data-align; read those rather than the options you passed,
since flipping can change either one.
<template>
<Tooltip.Root
:options="{
floating: { side: 'right', align: 'start', sideOffset: 8, flip: false },
}"
/>
</template>
Pass anchor to position against something else, and pass any of the eight
floating keys straight to the content to override the options for one instance.
<template>
<Tooltip.Content :anchor="() => cell" side="bottom" :side-offset="12" />
</template>
The content writes --rack-floating-anchor-width,
--rack-floating-anchor-height, --rack-floating-available-width,
--rack-floating-available-height and --rack-floating-transform-origin onto
the floating box, which is why an unstyled popup is never wider than the space
beside its trigger.
Where the popup renders
The content teleports to the end of <body>, which is what keeps a popup out
of a scroll container that would otherwise clip it. Turn it off and the boxes
render where the content sits.
<template>
<Tooltip.Root :options="{ portal: { disabled: true } }" />
</template>
Following the cursor
Set trackCursorAxis and the popup moves with the pointer along that axis while
staying anchored to the trigger on the other one. It is worth it on a wide
target, like a chart column or a timeline row, where the trigger’s centre is a
long way from where the reader is looking.
<template>
<Tooltip.Root :options="{ trackCursorAxis: 'x' }" />
</template>
The popup is measured against the pointer on the tracked axis and against the trigger on the other, so collision handling and an arrow both follow the point the popup is pinned to.
Styling from state
Every part writes its state to data-*, so style it with attribute selectors.
data-instant says the tooltip skipped its wait, which is usually a reason to
skip the animation too.
.popup[data-state='open'] {
animation: fade-in 120ms ease-out;
}
.popup[data-instant] {
animation-duration: 0ms;
}
.arrow[data-side='top'] {
top: 100%;
rotate: 180deg;
}
The content’s three boxes are not parts, so they are reached by their
data-scope or by the class each one ships. A class on Tooltip.Content is
applied to the popup, the box you paint.
<template>
<Tooltip.Content class="popup" />
</template>
.mirror-tooltip-floating {
--mirror-tooltip-floating-z-index: 80;
}
[data-scope='backdrop'] {
background: rgb(0 0 0 / 0.2);
}
Reaching the tooltip from anywhere
Give the root an id and useMirrorTooltip(id) opens and closes that tooltip
from anywhere in the app, which is how a tour or a validation hint shows one
without a pointer anywhere near it.
<script setup lang="ts">
const { isOpen, open, close } = useMirrorTooltip(TooltipId.Duplicate)
</script>
<template>
<Button @click="open()">Point it out</Button>
</template>
API reference
Module. A bundled options object on the root, and useMirrorTooltip(id)
as the programmatic API. Every part takes id to resolve a tooltip it is not
nested inside.
Tooltip.Group
Renders its children and nothing else. Optional, and only worth adding where
more than one tooltip sits together. It carries no delays: those belong to each
Tooltip.Root.
Options
Tooltip.Root
Renders its children. Owns the open state and the timings.
Props
Options
Emits
TooltipOpenReason is trigger-hover, trigger-focus, trigger-press,
outside-press, escape-key or imperative.
Slot props
Tooltip.Trigger
Renders a <button>, described by the popup while it is open.
Props
Slot props
Tooltip.Content
Renders everything that floats: the teleport, the floating box and the popup.
Everything in its default slot renders inside the popup. It anchors on
Tooltip.Trigger.
Layers
None of the three is a part, so each is styled through its data-scope or the
class it ships. A class on the content itself is applied to the popup.
Props
Every prop below but forceMount and anchor is an escape hatch over the
matching options.floating key, and only side and align stay reactive
after mount. forceMount is the hatch over options.forceMount.
Slot props
Tooltip.Arrow
Renders a <div aria-hidden="true">, absolutely positioned where the popup
meets the trigger. It must be nested inside a Tooltip.Content. The cross axis
is placed for you; which edge it sits on is yours, through data-side.
Slot props
Composable
useMirrorTooltip(id) reaches a tooltip from anywhere in the app.
Data attributes
CSS variables
Errors
Accessibility
The popup is a role="tooltip" and the trigger points at it with
aria-describedby while it is open, so a screen reader announces the label
after the control’s own name rather than instead of it. Focus never moves into
the popup and nothing inside it is reachable, so keep the popup to something
the reader can also get at another way.
A tooltip never opens from touch alone. There is no hover on a touchscreen, so put anything a reader on a phone needs outside the tooltip.
Everything else is left to the browser: the trigger is an ordinary button, and the tooltip listens for nothing else.