Tooltip
A description that appears when the pointer rests on a control or the keyboard reaches it. The reveal is one CSS rule — hover or focus-within on the wrapper, with the wait as a transition delay — so hand-written markup behaves exactly like the component. React adds three things only: the delay value, a grace window shared across the page, and the Escape dismissal WCAG 1.4.13 asks for.
- Stable
- React 19
- CSS-first reveal
- No color roles
Held open with data-open so it can be seen at rest. Everywhere else on this page, hover or tab to the trigger.
Hover the button, or tab to it. Nothing on this page is driving it — the stylesheet is.
Code
<Tooltip tip="Writes your changes to disk."><Button>Save</Button></Tooltip><span class="wayra-tooltip-anchor" style="margin: 3rem 3.5rem"> <button type="button" class="wayra-button" aria-describedby="usage-tip">Save</button> <span role="tooltip" id="usage-tip" class="wayra-tooltip wayra-overlay"><span class="wayra-tooltip-body">Writes your changes to disk.</span></span></span>Two props carry the component: tip, which is what it says, and children, which is the
single element it describes. Everything else is optional. tip is required because it is
the trigger’s accessible description — the component gives the tip an id and merges that id
into the trigger’s aria-describedby.
Playground
Section titled “Playground”Change the props and copy the code.
Generated code
<Tooltip tip="Writes your changes to disk.">
<Button>Save</Button>
</Tooltip>Placement
Section titled “Placement”Code
<Tooltip placement="block-start" tip="Above">…</Tooltip><Tooltip tip="Below">…</Tooltip><Tooltip placement="inline-start" tip="Before">…</Tooltip><Tooltip placement="inline-end" tip="After">…</Tooltip><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 5.5rem"> <button type="button" class="wayra-button" aria-describedby="place-block-start">block-start</button> <span role="tooltip" id="place-block-start" class="wayra-tooltip wayra-overlay" data-placement="block-start"><span class="wayra-tooltip-body">Above</span></span></span><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 5.5rem"> <button type="button" class="wayra-button" aria-describedby="place-block-end">block-end</button> <span role="tooltip" id="place-block-end" class="wayra-tooltip wayra-overlay"><span class="wayra-tooltip-body">Below</span></span></span><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 5.5rem"> <button type="button" class="wayra-button" aria-describedby="place-inline-start">inline-start</button> <span role="tooltip" id="place-inline-start" class="wayra-tooltip wayra-overlay" data-placement="inline-start"><span class="wayra-tooltip-body">Before</span></span></span><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 5.5rem"> <button type="button" class="wayra-button" aria-describedby="place-inline-end">inline-end</button> <span role="tooltip" id="place-inline-end" class="wayra-tooltip wayra-overlay" data-placement="inline-end"><span class="wayra-tooltip-body">After</span></span></span>Four sides, named on the logical axes. block-end is the default — where a tip conventionally
sits. The component writes data-placement for every value, the default included; hand-written
markup may leave it off and gets block-end.
The two block placements centre the tip on the inline axis with a physical transform,
because translate is already spoken for by the entrance animation, so the writing direction
reaches it as a sign: under dir="rtl" the shift flips. The inline pair changes physical side
with the direction on its own, and its arrow turns half a turn to keep pointing at the trigger.
Every side enters from its trigger. The tip starts a little closer to the trigger — the short
lift every overlay shares — and slides into place along its own axis, so the inline pair moves
sideways and never rises into a neighbour above it. It also grows out of the middle of the edge that faces the trigger. The
centring transform and the inline pair’s entrance are physical, so both take the same sign
from the writing direction.
:dir() only arrived in Chrome 120, so the stylesheet also reads a dir="rtl" attribute on the
tip or any ancestor. Below Chrome 120 that attribute is all it has, and the one case it misses
there is an LTR island inside a right-to-left page.
There is no collision detection. A tip near the edge of the viewport stays on the side
you asked for; nothing measures and flips it. That is the price of staying out of the top
layer, and it is a fair one for a box that is at most 18rem wide and sits a few pixels from
its trigger — but pick the side that has room.
Code
<Tooltip tip="Writes your changes to disk."><Button>No arrow</Button></Tooltip><Tooltip arrow tip="Writes your changes to disk."><Button>Arrow</Button></Tooltip><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 3.5rem"> <button type="button" class="wayra-button" aria-describedby="arrow-off">No arrow</button> <span role="tooltip" id="arrow-off" class="wayra-tooltip wayra-overlay"><span class="wayra-tooltip-body">Writes your changes to disk.</span></span></span><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 3.5rem"> <button type="button" class="wayra-button" aria-describedby="arrow-on">Arrow</button> <span role="tooltip" id="arrow-on" class="wayra-tooltip wayra-overlay" data-arrow="true"><span class="wayra-tooltip-body">Writes your changes to disk.</span></span></span>Off by default. The arrow is a square rotated 45 degrees and then cut in half with a
clip-path, so only the triangle outside the box survives — the covered half would otherwise
paint over the tip’s border, which is what carries the outline around the point. A negative
z-index would not have done it: a child paints after its parent’s background either way.
A tip with a title
Section titled “A tip with a title”Code
<Tooltiparrowtitle="Save"tip={<>Writes your changes to disk. <kbd>⌘S</kbd></>}><Button>Save</Button></Tooltip><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 3.5rem"><button type="button" class="wayra-button" aria-describedby="rich-tip">Save</button><span role="tooltip" id="rich-tip" class="wayra-tooltip wayra-overlay" data-variant="rich" data-arrow="true"><span class="wayra-tooltip-title">Save</span><span class="wayra-tooltip-body">Writes your changes to disk. <kbd>⌘S</kbd></span></span></span>There is no variant prop. A tip that has a title is the rich one: the title becomes
the heading, tip becomes the line under it, and the element records the fact as
data-variant="rich" so hand-written markup can ask for the same shape.
A <kbd> inside the tip gets a chip: a wash of base-content behind text that keeps
base-content, so the shortcut never costs the surface its contrast ratio. It inherits the
tip’s own font rather than a monospace stack, because a menubar shortcut is set in the UI
face.
The reveal is one CSS rule
Section titled “The reveal is one CSS rule”This is the whole mechanism, and it is worth reading once because everything else on this page follows from it:
.wayra-tooltip-anchor:is(:hover, :focus-within, [data-open]) > .wayra-tooltip { visibility: visible; opacity: 1; translate: 0; scale: 1; transition-delay: var(--wayra-tooltip-delay);}Three arms, one declaration block. :hover is the pointer, :focus-within is the keyboard —
on the wrapper, so focus landing on the trigger inside it counts — and [data-open] is the
page driving it by hand.
Four consequences fall out of writing it that way:
- Hand-written markup is not a second implementation. The wrapper class, the tip’s co-class pair and the attributes are the entire contract; a page with no React in it shows its tips on hover and on focus. Every static example above is exactly that.
- The wait costs nothing. It is
transition-delay, not a timer, so nothing runs while the pointer is elsewhere. It also has to be in place before the hover starts, which is why the component writesdelayas an inline custom property and the grace window as an attribute, rather than reacting to a pointer event. - The tip is hidden with
visibility, neverdisplay. Adisplay: nonebox cannot be transitioned into without@starting-style, and — the reason that matters here — it cannot be crossed by a pointer. A::beforepseudo-element bridges the gap between trigger and tip, so moving the pointer onto the tip keeps:hoveralive. That is WCAG 1.4.13’s hoverable clause, paid for in two declarations. - It is not in the top layer. The tip takes no focus and needs no light dismiss, so a
child of its own wrapper is correctly placed in every engine the library supports. The cost
is real and worth stating: an ancestor with
overflow: hiddenclips the tip.popover="hint"is the documented upgrade path if that ever has to change.
React writes one custom property and two attributes: data-dismissed through the render,
and data-instant straight onto each tip with setAttribute, outside it. That is all it
does to the paint.
The delay, and the grace window
Section titled “The delay, and the grace window”delay defaults to 500 milliseconds. Hand-written markup gets the same default from the
stylesheet; the component writes the prop inline as --wayra-tooltip-delay.
The delay is for the pointer. It filters a cursor that is only passing over the trigger; a keyboard user who tabs onto the trigger has asked for the tip, so keyboard focus shows it at once:
.wayra-tooltip-anchor:has(:focus-visible) > .wayra-tooltip { transition-delay: 0s;}The browser decides what counts as keyboard focus. A click on a button focuses it without
:focus-visible, so that tip waits like the pointer that made the click.
The grace window is the menubar behaviour: once you have read one tip, the next one should
not make you wait again. Leaving a trigger — pointer out or focus out — opens a shared
400ms window, and while it is open every mounted tooltip on the page reveals instantly. The
component marks it as data-instant on each tip, and the stylesheet drops the wait to zero:
.wayra-tooltip-anchor > .wayra-tooltip[data-instant] { transition-delay: 0s;}It is pushed rather than pulled on purpose. The wait is a transition-delay, so it must
already be zero when the next hover begins; reading a timestamp after the pointer arrives
would be one render too late. The attribute is written straight onto the tips, so opening
and closing the window re-renders none of them.
Escape dismisses it
Section titled “Escape dismisses it”Content revealed on hover has to be dismissable without moving the pointer or the focus.
Pressing Escape while the trigger is hovered or focused sets data-dismissed on the tip,
and that attribute beats the reveal:
.wayra-tooltip-anchor > .wayra-tooltip[data-dismissed] { visibility: hidden; opacity: 0; transition-delay: 0s;}The tip stays hidden while the trigger still has the pointer or the focus, and comes back once both have left — which is what a dismissal means, rather than a flicker.
The key listener is on the anchor, never on the document, and it is attached only while
the tip is showing. A pointer resting on a tipped control must not swallow the Escape an
unrelated dialog is waiting for. While the tip is showing it is the innermost open surface,
so the press is consumed there and preventDefault keeps the browser’s own close request
from running afterwards. A tip still waiting out its delay under a pointer that has just
arrived is not showing yet, so it leaves the key alone: inside a Modal,
the first Escape closes the modal. A tip reached from the keyboard is already showing, so its
first Escape hides the tip and the second closes the modal.
Forcing it open
Section titled “Forcing it open”Code
<Tooltip open tip="Writes your changes to disk."><Button>Held open</Button></Tooltip><span class="wayra-tooltip-anchor" data-open="true" style="margin: 3rem 3.5rem"> <button type="button" class="wayra-button" aria-describedby="forced-tip">Held open</button> <span role="tooltip" id="forced-tip" class="wayra-tooltip wayra-overlay"><span class="wayra-tooltip-body">Writes your changes to disk.</span></span></span>open writes data-open on the wrapper, which is the third arm of the reveal rule. It skips
the delay — a tip the page opened itself has nothing to wait for — and the hover and focus
arms stay live alongside it.
It exists for three cases: a documentation page like this one, an onboarding step that points at a control, and a touch affordance the app drives from a tap. Since it is an attribute, a page with no JavaScript at all can hold a tip open by writing it.
Choosing a trigger
Section titled “Choosing a trigger”Any single element is a valid trigger — the component clones one attribute onto it and adds nothing else. What that buys you, and what it costs, is one rule:
A trigger that cannot take focus makes the tip pointer-only, and a pointer-only tip fails
WCAG 1.4.13 for anyone using a keyboard. A Button, a link or any real control already takes
focus. A <span> around a word does not, so it needs tabIndex={0} from you — plus a role
and an accessible name where the element carries neither. The component cannot supply them,
because it does not know the child’s semantics.
<p> The{' '} <Tooltip tip="The vertical space between two lines of text."> <span tabIndex={0} style={{ textDecoration: 'underline dotted', cursor: 'help' }}> leading </span> </Tooltip>{' '} is measured in the same unit.</p>The dotted underline is yours too. The wrapper is an inline-flex box, so a trigger inside
running text sits on the line like a word — but it is atomic and cannot break across two
lines. Wrap a word, not a clause.
For a small glyph at the end of a line, reach for a real Button in the ghost variant
rather than a focusable role="img" span. A button takes focus on its own, announces both a
name and something to do, and already wears the library’s focus ring; a focusable image
announces as a picture the user cannot operate.
Tooltip, Popover, or title
Section titled “Tooltip, Popover, or title”| Reach for | When | Because |
|---|---|---|
the native title attribute |
a low-value aside on a non-critical control | free, and the OS renders it — but you cannot style it, time it, or reach it on touch |
| Tooltip | a short description of a control that is already named | hover and focus, styled, dismissable, and wired as aria-describedby |
| Popover | anything with a control in it, or that must survive a click | a tooltip cannot hold a link or a button: the pointer can reach it, but the keyboard never can |
The dividing line is interactivity, not size. The moment the content needs to be clicked, copied or tabbed into, it is a popover — a tip is unreachable by keyboard once you leave the trigger, and putting a control in one strands it.
No color roles
Section titled “No color roles”There is no color prop, and that is deliberate. A tooltip describes; it does not signal.
A tip that turns red is a status message wearing a label’s clothes, and
Alert is where urgency belongs. The second reason is contrast: a role on
this surface would have to carry its own contrast contract for text one size down, on a
transient box no automated audit can measure while it is closed.
API reference
Section titled “API reference”TooltipProps extends ComponentProps<"span"> minus children and title, both redefined
below. Everything else — id, style, className, ref, data-* — lands on the wrapper
<span>. An onFocus, onBlur, onPointerEnter or onPointerLeave of yours runs first,
and the component’s own wiring on the same event still runs after it.
| Prop | Type | Default | Description |
|---|---|---|---|
tip |
ReactNode |
— required | What the tip says. It is the trigger’s accessible description. |
children |
ReactElement |
— required | The trigger, as a single element. Cloned with aria-describedby and nothing else. |
placement |
"block-start" | "block-end" | "inline-start" | "inline-end" |
"block-end" |
Which side the tip sits on. Travels as data-placement, the default included. |
arrow |
boolean |
false |
Draws the pointer that ties the tip to its trigger. |
delay |
number |
500 |
Milliseconds the pointer must rest first. Keyboard focus skips it, and so does the shared grace window. |
title |
ReactNode |
— | Turns the tip into the rich two-part shape. There is no variant prop. |
open |
boolean |
— | Holds the tip open through data-open, with no delay. Hover and focus stay live. |
unstyled |
boolean |
false |
Drops every library class; the role, the id wiring and every data-* stay. |
The styling contract is the class plus the attributes:
<span class="wayra-tooltip-anchor"> <button type="button" class="wayra-button" aria-describedby="save-tip">Save</button> <span role="tooltip" id="save-tip" class="wayra-tooltip wayra-overlay"> <span class="wayra-tooltip-body">Writes your changes to disk.</span> </span></span>wayra-overlay is a co-class, not a typo: it is the shared surface recipe every popup in the
library wears, re-pointed here to a label’s proportions — a field’s corner radius and no
scroll cap. Like every floating surface it is flat: its own border separates it from the
page, with no shadow.
Under unstyled there is no stylesheet behind the tip, so it is simply always visible. The
reveal is the CSS; opting out of the CSS is opting out of the reveal.
Accessibility
Section titled “Accessibility”WCAG 1.4.13 (Content on Hover or Focus) has three clauses, and the component answers each with a specific mechanism:
- Dismissable.
Escapehides the tip without moving the pointer or the focus. The listener is scoped to the anchor and answers only while the tip is actually showing — not during its delay — so it never steals the key from a dialog, elsewhere on the page or around the trigger. - Hoverable. The tip is hidden with
visibility, and a::beforepseudo-element bridges the gap to the trigger, so the pointer can travel onto the tip and the content stays put. - Persistent. Nothing times the tip out. It stays until the pointer leaves, the focus
moves, or
Escapeis pressed.
And the rest of the contract:
- The tip is a description, never a name.
role="tooltip"plusaria-describedbyon the trigger. A screen reader reads the trigger’s name first and the description after, which is why a control still needs its own label. - A
describedbythe trigger already carried is kept, with the tip’s id appended to it. - The tip never enters the tab order. It has no
tabindex, and focus stays on the trigger the whole time. - A trigger that cannot take focus makes the tip pointer-only. See choosing a trigger.
- Forced colors: the arrow is redrawn from
CanvasTextonCanvas, and the shared overlay recipe paints the surface the same way. - Keyboard focus never waits. A trigger reached with
Tab(:focus-visible) shows its tip at once; the delay is only for the pointer. - Reduced motion: like the rest of the overlay family, the tip fades in and out in 160ms where it stands, with no lift. The wait before it appears is not motion and stays.
Do and don’t
Section titled “Do and don’t”Do
The glyph is named with aria-label and described by the tip. Two different jobs, two different attributes.
Don't
The tip used as the label. The button's name is the bare glyph, and the words that would explain it arrive as a description — second, and only if the reader announces descriptions at all.
Do
aria-disabled — Button's softDisabled. The control is inert but still focusable and still hoverable, so the tip that explains it can actually be read.
Don't
A real disabled button takes no focus and fires no pointer events, so this tip can never appear — which is why nothing is showing above. The one explanation the user needs is the one they cannot reach.