📖 Developer Guide

How to Build a Tooltip in React — and What Stays Hard Afterwards

A React tooltip looks like an afternoon: some state, an absolutely positioned div, a bit of CSS. Then it gets clipped by a scrolling panel, disappears behind a modal, never opens on keyboard focus, and does something unhelpful on a phone. This guide builds the version that survives all four — trigger hook, portal, collision-aware positioning and the ARIA wiring — and is honest about what is still hard when it works.

📅 Updated September 2026 ⏱ 12 min read ✍️ By Kompassify
The four layers of a React tooltip component — trigger state machine, portal, positioning engine and ARIA wiring — shown as a stack over the trigger element

Every front-end codebase eventually grows a <Tooltip>. It usually starts as twenty lines someone wrote in a hurry: a boolean, onMouseEnter, an absolutely positioned div. It works on the page it was written for, and then it meets the rest of the application.

It gets cropped by a table with overflow: auto. It renders behind a dialog no matter how much z-index you throw at it. It never appears for anyone navigating by keyboard. It flickers when the pointer crosses the two-pixel gap between the trigger and the bubble. On a phone, the first tap opens it and swallows the click that was supposed to save the form.

None of that is exotic. It is the normal difficulty of the component, and it is why a tooltip is a better interview question than it looks. This guide builds one properly in React, in four layers, and then lists what remains hard after the component is done.

Key Takeaways

  • Four layers, not one component. Trigger state machine, portal, positioning engine, ARIA wiring — each solves a different failure.
  • z-index will not save you. Clipping and stacking are ancestor problems; the fix is a portal or the top layer.
  • Focus is not optional. A hover-only tooltip does not exist for keyboard users, and that is usually an accessibility defect, not a nice-to-have.
  • Describe or label — pick one. aria-describedby on a control with no accessible name leaves it nameless.
  • Delay is a feature. Open delay stops flicker on pointer transit; close delay lets the pointer reach the bubble.
  • A UI tooltip is not an onboarding tooltip. Different problem, different lifecycle, and usually a different owner.

What a Tooltip Actually Has to Do

Before any code, the specification. A tooltip that ships into a real design system has to:

Eight requirements, four of which are invisible on the developer's machine. Here is the shape of the component that satisfies them.

The four layers of a working tooltip 1 · TRIGGER HOOK pointerenter · focus · blur Escape · open + close delays owns: is it open, and why 2 · PORTAL createPortal → document.body escapes overflow + stacking owns: where it lives in the DOM 3 · POSITIONING measure trigger · flip · shift · arrow re-measure on scroll and resize owns: the numbers 4 · ARIA WIRING — the layer that decides whether the tooltip exists for everyone useId() → stable id · aria-describedby on the trigger · role="tooltip" on the bubble …or aria-labelledby instead, when the trigger has no other accessible name Skip any one layer and the tooltip still “works” — for a subset of your users, on a subset of your pages.

Each layer fixes a failure the others cannot see.


1. The Trigger: Hover, Focus, Delay and Escape

Open state is where most tooltips are quietly wrong. A single useState(false) toggled by onMouseEnter and onMouseLeave misses focus, misses Escape, and flickers. What you want is a small hook that owns the timers.

useTooltip.ts
import { useCallback, useEffect, useRef, useState, useId } from 'react' type Options = { openDelay?: number; closeDelay?: number } export function useTooltip({ openDelay = 180, closeDelay = 80 }: Options = {}) { const [open, setOpen] = useState(false) const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const id = useId() // stable across SSR and hydration const clear = () => { if (timer.current) clearTimeout(timer.current) } const show = useCallback((immediate = false) => { clear() if (immediate) return setOpen(true) // focus should not wait timer.current = setTimeout(() => setOpen(true), openDelay) }, [openDelay]) const hide = useCallback(() => { clear() timer.current = setTimeout(() => setOpen(false), closeDelay) }, [closeDelay]) useEffect(() => () => clear(), []) // never leak a timer useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open]) const triggerProps = { 'aria-describedby': open ? id : undefined, onPointerEnter: (e: React.PointerEvent) => { if (e.pointerType === 'mouse') show() }, onPointerLeave: (e: React.PointerEvent) => { if (e.pointerType === 'mouse') hide() }, onFocus: () => show(true), onBlur: () => setOpen(false), } return { open, id, show, hide, triggerProps } }

Three decisions in there are worth defending:

Escape must not move focus. Per the ARIA authoring practices, dismissing a tooltip leaves focus exactly where it was — on the trigger. A tooltip is not a dialog; nothing about it should trap or restore focus.


2. Rendering It Somewhere Safe

This is the bug that generates the most Stack Overflow traffic, and the one most often "fixed" with z-index: 9999. Two different ancestor behaviours cause it:

Clipping

Any ancestor with overflow: hidden | auto | scroll crops descendants that stick out of its box. Tables, side panels, cards with rounded corners and virtualised lists all do this by design.

Stacking

An ancestor with a transform, filter, backdrop-filter, will-change, contain, or an opacity below 1 creates a stacking context. Your tooltip's z-index is then only compared against its siblings inside that context — it can never climb above it.

The reliable answer is to render the bubble somewhere else in the DOM and position it with viewport coordinates:

Tooltip.tsx — the portal
import { createPortal } from 'react-dom' function TooltipBubble({ id, style, children }) { if (typeof document === 'undefined') return null // SSR: render nothing on the server return createPortal( <div id={id} role="tooltip" style={{ position: 'fixed', top: 0, left: 0, ...style }} className="tooltip-bubble" > {children} </div>, document.body, ) }

Two notes. position: fixed with a transform applied from measured coordinates is easier to reason about than absolute, because it removes the offset-parent question entirely. And rendering null on the server keeps hydration honest — there is no trigger geometry to measure before the browser has laid the page out.

The native alternative. The Popover API puts an element in the browser's top layer, which sidesteps stacking contexts without a portal, and CSS anchor positioning aims to remove the measuring code as well. Popover support is now broad enough to build on; anchor positioning is still arriving browser by browser, so treat it as progressive enhancement over a JavaScript fallback rather than the baseline. The layers in this guide do not change — the platform just takes over two of them.


3. Positioning: Flip, Shift and the Arrow

Naive positioning reads getBoundingClientRect() on the trigger and adds an offset. That is correct until the trigger is near an edge — then the tooltip renders half off-screen, or under the viewport, and the information is gone.

The two behaviours you need are flip (if there is no room above, go below) and shift (if there is no room to the left, slide along the cross axis until there is, while keeping the arrow on the trigger).

Collision handling: the two behaviours that matter PREFERRED tooltip trigger room above → place above arrow centred on trigger FLIP viewport top trigger tooltip no room above → flip to the opposite side SHIFT viewport edge tooltip trigger would overflow sideways → slide, keep the arrow anchored Both must re-run whenever an ancestor scrolls, the window resizes, or the content reflows.

Flip changes the side. Shift slides along it. The arrow stays on the trigger through both.

You can hand-roll this. You should not. Collision detection against the right boundary, tracking the trigger through nested scroll containers, and keeping an arrow visually attached while the bubble slides is where the subtle bugs live. A positioning library — Floating UI is the common choice in the React ecosystem — reduces the whole layer to a few lines:

Tooltip.tsx — positioning
import { computePosition, autoUpdate, offset, flip, shift, arrow } from '@floating-ui/dom' useLayoutEffect(() => { if (!open || !triggerRef.current || !bubbleRef.current) return // autoUpdate re-runs on scroll, resize, and element resize — // including ancestor scroll containers you never registered a listener on. return autoUpdate(triggerRef.current, bubbleRef.current, () => { computePosition(triggerRef.current!, bubbleRef.current!, { placement: 'top', strategy: 'fixed', middleware: [ offset(8), // gap between trigger and bubble flip(), // top → bottom when it does not fit shift({ padding: 8 }), // slide along the cross axis arrow({ element: arrowRef.current! }), ], }).then(({ x, y, placement, middlewareData }) => { setStyle({ transform: `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)` }) setArrow(middlewareData.arrow) setSide(placement) // so CSS can rotate the arrow }) }) }, [open])

Two details that are easy to miss. Round the coordinates — sub-pixel translations make text render blurry on non-retina displays. And use useLayoutEffect, not useEffect, so the first paint already has the right position; otherwise the tooltip visibly jumps from the top-left corner on open.


4. Accessibility: Describe or Label, Never Both

This is the layer that gets skipped, and the one that turns a tooltip from a convenience into a defect. The first question is not "which ARIA attribute" — it is what is this tooltip for?

Situation What the tooltip is Wiring
Button already reads "Export", tooltip adds "Downloads a CSV of the current view" A description aria-describedby on the trigger → bubble with role="tooltip"
Icon-only button whose only text is the tooltip word "Export" A label aria-label on the button (or aria-labelledby → the bubble)
Bubble contains a link, a "Learn more", or anything clickable Not a tooltip at all Use a popover with proper focus management

The failure mode worth remembering: an icon-only button with only aria-describedby has no accessible name. A screen reader announces "button" and then, maybe, a description. The control is unusable, and automated accessibility checks often miss it because the attribute is technically present and technically valid.

Tooltip.tsx — wiring it together
export function Tooltip({ label, children, describes = true }) { const { open, id, triggerProps } = useTooltip() const trigger = React.cloneElement(children, { ...triggerProps, ref: triggerRef, // a description when the control is already named; // a label when the tooltip IS the only name it has 'aria-describedby': describes && open ? id : undefined, 'aria-label': describes ? undefined : label, }) return ( <> {trigger} {open && describes && <TooltipBubble id={id} style={style}>{label}</TooltipBubble>} </> ) }

Round it off with the details that cost nothing and are always missed: never put focusable content in the bubble; keep the text short enough that it does not need to be re-read; respect prefers-reduced-motion in the fade; and make sure the tooltip's contrast ratio survives the dark background it is probably drawn on. The same rules apply whatever the framework — the underlying pattern is covered in our explainer on what a tooltip is and the UX conventions around it.


5. Touch Devices: The Hover That Never Comes

There is no hover on a touch screen. A tooltip that only opens on mouseenter is simply invisible to a phone user — and the usual "fix", opening on tap, quietly steals the tap that was meant for the button underneath.

Detect the pointer, not the user agent
const useCoarsePointer = () => { const [coarse, setCoarse] = useState(false) useEffect(() => { const mq = window.matchMedia('(hover: none), (pointer: coarse)') const sync = () => setCoarse(mq.matches) sync(); mq.addEventListener('change', sync) return () => mq.removeEventListener('change', sync) }, []) return coarse }

With that in hand you have two honest options, and one dishonest one:

The rule that resolves most of these arguments: if information is essential, it must not live only in a tooltip — on any device. A tooltip is a shortcut for people who already know what they are looking at, never the only path to a fact the user needs.


What Stays Hard After It Works

The component is done, reviewed, in the design system. Here is what still generates tickets.


A UI Tooltip Is Not an Onboarding Tooltip

Once the component exists, someone will ask it to do a different job: show a tip to new users on the reports page until they have created their first report. That request looks like a small extension. It is a different system.

UI tooltip Onboarding tooltip
Who opens it The user, by hovering or focusing The product, when conditions are met
Who sees it Everyone, identically One segment, once, until dismissed or completed
State it needs None — it is stateless Seen / dismissed / completed, per user, across devices
How often the copy changes Rarely Constantly — it is content, not UI
Who owns it Front-end engineering Product, onboarding or customer success

Building the second on top of the first means shipping targeting rules, per-user persistence, sequencing, analytics and a copy-editing workflow — through your release cycle, every time marketing rewrites a sentence. That is the reason onboarding tooltips and product tours are usually configured rather than coded, and the same argument applies at larger scale in our build vs buy analysis.


Build It, or Configure It?

A clean split that holds up in practice:

Build Product surface tooltips

Icon buttons, truncated cells, form hints, keyboard shortcuts. They are part of the interface, they ship with the component, and they belong in your design system.

Configure Guidance and adoption

"Try this new filter", "Finish setting up billing", anything targeted at a segment or a moment in the user's life. Content that changes weekly should not need a deploy.

Never Essential information

If the user cannot complete the task without it, it is not a tooltip. Put it on the page.

Kompassify covers the middle column without touching the React codebase: tooltips, hotspots, checklists and tours are pointed at elements in your live product from a visual editor, targeted by segment, and they report their own completion — so a change of wording is an edit, not a release. Our guide to building a product tour in React walks through the same trade-off for multi-step flows, where the state machine is considerably worse than a tooltip's.

Building in-app guidance visually on a live product instead of writing a React tooltip component for onboarding content

Same anchor targets, same overlay problem — solved once, outside the release cycle.

Keep the component. Skip the content pipeline.

Kompassify lets product and onboarding teams add tooltips, hotspots, checklists and guided tours to a React app without shipping a release for every copy change — targeted by segment, with adoption data on each one. Free up to 100 monthly active users, plans from $129/month, GDPR-compliant and EU-hosted.

Start for free →

A Pre-Ship Checklist

Before the tooltip goes into the design system

  • Opens on focus, not only on hover — verified with the keyboard alone
  • Escape closes it and focus does not move
  • Renders through a portal; tested inside a scrolling panel and a transformed ancestor
  • Flips and shifts at all four viewport edges
  • Correct relationship chosen: described, or labelled — and icon-only buttons still have a name
  • No focusable content inside the bubble
  • Something sensible happens on a coarse pointer
  • Honours prefers-reduced-motion, and passes contrast on its own background
  • Behaves when the trigger unmounts while open

The One-Sentence Version

A React tooltip is a trigger hook, a portal, a positioning pass and an ARIA relationship — build those four properly, borrow the geometry from a positioning library, and keep onboarding content out of the component entirely.

Frequently Asked Questions

How do you create a tooltip in React?

A production React tooltip has four parts. First, a trigger hook that opens on mouseenter and focus and closes on mouseleave, blur and Escape, with an open delay of roughly 150–300ms and a shorter close delay so pointer travel between trigger and tooltip does not dismiss it. Second, a portal — createPortal into document.body — so the tooltip is not clipped by an ancestor with overflow: hidden or trapped under a stacking context. Third, a positioning pass that measures the trigger and the viewport and flips or shifts the tooltip when it would overflow. Fourth, the ARIA wiring: a stable id from useId, aria-describedby on the trigger, and role="tooltip" on the bubble. Skipping the fourth part produces a tooltip that only sighted mouse users can read.

Why is my React tooltip cut off or hidden behind other elements?

Almost always because it is rendered inside an ancestor that clips or stacks it. Any ancestor with overflow: hidden, auto or scroll will crop a child positioned outside its box, and any ancestor with a transform, filter, backdrop-filter, will-change, contain or a non-auto opacity creates a containing block and a stacking context that no z-index on the tooltip can escape. The fix is not a larger z-index; it is to render the tooltip through createPortal into document.body — or into a top-layer popover — and position it with fixed coordinates measured from the trigger.

Should I use a library or write my own React tooltip?

Write the component, but do not write the geometry. Collision detection, flipping, shifting along the cross axis, arrow placement and keeping position in sync while ancestors scroll or resize is a genuinely hard problem that a positioning library solves better than a hand-rolled getBoundingClientRect pass. Owning the component keeps the markup, ARIA and styling under your control, which is where design systems need the flexibility. The exception is when the tooltip is not really a tooltip but onboarding guidance aimed at a specific user segment — that is content, not UI, and it should not be shipped through your release cycle at all.

How do you make a React tooltip accessible?

Decide first whether the tooltip is a description or a label. A description — extra detail about a control that already has a name — uses aria-describedby on the trigger pointing at a bubble with role="tooltip". A label — an icon-only button whose only text is the tooltip — needs aria-label or aria-labelledby instead, because a describedby relationship on a nameless control leaves the control unnamed. Beyond that: open on focus as well as hover, close on Escape without moving focus, never put interactive elements inside the bubble, and honour prefers-reduced-motion in the transition.

How do React tooltips work on touch devices?

They mostly do not, and that is the point. There is no hover on a touch screen, so a hover-only tooltip is invisible to a phone user, while naive tap-to-open handlers hijack the tap that was meant for the control underneath. The workable pattern is to detect the coarse pointer with a media query rather than user-agent sniffing, then either suppress the tooltip entirely and make sure the information also exists somewhere visible, or promote it to a tap-dismissible popover that does not swallow the trigger's own action. Anything essential should never live only inside a tooltip on any device.

What is the difference between a React tooltip and an onboarding tooltip?

A UI tooltip is a passive, user-invoked hint: it appears because someone hovered or focused a control, it describes that control, and it says the same thing to everyone. An onboarding tooltip is proactive guidance: it appears because a particular user has not done a particular thing yet, it is targeted by segment and sequenced with other steps, and it needs dismissal state, analytics and copy that changes weekly. Building the second on top of the first is where teams get stuck, because the hard parts are targeting, persistence and iteration speed rather than positioning — which is why product and onboarding teams usually configure those guides in a tool instead of shipping them through the front-end release cycle.