Skip to content

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
Writes your changes to disk.

Held open with data-open so it can be seen at rest. Everywhere else on this page, hover or tab to the trigger.

Writes your changes to disk.

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.

Change the props and copy the code.

Writes your changes to disk.
placement
delay
shape
state
Generated code
TSX
<Tooltip tip="Writes your changes to disk.">
  <Button>Save</Button>
</Tooltip>
Above Below Before After
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.

Writes your changes to disk. Writes your changes to disk.
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.

SaveWrites your changes to disk. ⌘S
Code
<Tooltip
arrow
title="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.

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 writes delay as an inline custom property and the grace window as an attribute, rather than reacting to a pointer event.
  • The tip is hidden with visibility, never display. A display: none box cannot be transitioned into without @starting-style, and — the reason that matters here — it cannot be crossed by a pointer. A ::before pseudo-element bridges the gap between trigger and tip, so moving the pointer onto the tip keeps :hover alive. 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: hidden clips 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.

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.

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.

Writes your changes to disk.
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.

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.

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.

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.

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.

WCAG 1.4.13 (Content on Hover or Focus) has three clauses, and the component answers each with a specific mechanism:

  • Dismissable. Escape hides 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 ::before pseudo-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 Escape is pressed.

And the rest of the contract:

  • The tip is a description, never a name. role="tooltip" plus aria-describedby on 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 describedby the 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 CanvasText on Canvas, 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

Charged within 24 hours.

The glyph is named with aria-label and described by the tip. Two different jobs, two different attributes.

Don't

Billing cycle

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

Add a title first.

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

Add a title first.

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.