Skip to content

Tooltip

Names a control the moment someone hovers it or tabs onto it.

View source View as Markdown

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.

App.vue
<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-label or 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.Content teleports to the end of <body> unless you turn that off with options.portal.disabled. Leave it on where the trigger sits inside anything with overflow: hidden.
  • Wrap a row of triggers in one Tooltip.Group and 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.

NameRequiredDescription
Tooltip.GroupOptional. 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.ArrowRenders 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

OptionTypeDefault
timeout
number400

Tooltip.Root

Renders its children. Owns the open state and the timings.

Props

PropTypeDefault
id
stringgenerated
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
TooltipOptionssee below

Options

OptionTypeDefault
delay
number600
closeDelay
number0
disabled
booleanfalse
hoverable
booleantrue
trackCursorAxis
'none' | 'x' | 'y' | 'both''none'
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.triggerPress
booleantrue
forceMount
booleanfalse
backdrop
booleanfalse
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''top'
floating.sideOffset
number4
floating.align
'start' | 'center' | 'end''center'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.collisionPadding
number8
transition.popup
string'mirror-tooltip-popup'
transition.backdrop
string'mirror-tooltip-backdrop'

Emits

EmitPayload
update:open
boolean
openChange
(open: boolean, reason: TooltipOpenReason)

TooltipOpenReason is trigger-hover, trigger-focus, trigger-press, outside-press, escape-key or imperative.

Slot props

PropType
open
boolean

Tooltip.Trigger

Renders a <button>, described by the popup while it is open.

Props

PropTypeDefault
disabled
booleanoptions.disabled

Slot props

PropType
open
boolean
disabled
boolean
side
TooltipSide | undefined
align
TooltipAlign | undefined

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.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-tooltip, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
div[role=tooltip]data-tooltip, data-side, data-align, data-state, data-instant

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.

PropTypeDefault
forceMount
booleanoptions.forceMount
anchor
HTMLElement | (() => HTMLElement | null)the trigger
side
'top' | 'right' | 'bottom' | 'left''top'
sideOffset
number4
align
'start' | 'center' | 'end''center'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
collisionPadding
number8

Slot props

PropType
open
boolean
side
TooltipSide
align
TooltipAlign

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

PropType
side
TooltipSide
align
TooltipAlign

Composable

useMirrorTooltip(id) reaches a tooltip from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
open
() => void
close
() => void
toggle
() => void
side
ComputedRef<TooltipSide | undefined>
align
ComputedRef<TooltipAlign | undefined>

Data attributes

PartAttributeValue
Tooltip.Trigger, Tooltip.Content
data-tooltip
the instance ID
Tooltip.Content
data-scope
backdrop | floating | popup
Tooltip.Content, Tooltip.Arrow
data-state
open | closed
Tooltip.Trigger
data-popup-open
true
Tooltip.Trigger
data-disabled
true
Tooltip.Trigger, Tooltip.Content, Tooltip.Arrow
data-side
top | right | bottom | left
Tooltip.Trigger, Tooltip.Content, Tooltip.Arrow
data-align
start | center | end
Tooltip.Content
data-anchor-hidden
true
Tooltip.Content, Tooltip.Arrow
data-instant
delay | focus | dismiss

CSS variables

VariableDefault
--mirror-tooltip-popup-max-width
var(--rack-floating-available-width, none)
--mirror-tooltip-floating-z-index
50
--mirror-tooltip-backdrop-position
fixed
--mirror-tooltip-backdrop-z-index
50

Errors

Code
missing_tooltip_context
missing_tooltip_content

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.

KeyBehaviour
Tab onto the triggerOpens straight away, without the hover delay, and marks the popup data-instant="focus".
Tab awayCloses straight away.
EscapeCloses the tooltip while it is the topmost layer. Focus stays where it is.

Everything else is left to the browser: the trigger is an ordinary button, and the tooltip listens for nothing else.