A Lightweight Approach to Tooltips in React
You need a tooltip. Maybe it’s a hint for an icon button, or context for a truncated label. Before reaching for a full UI framework or a legacy library like Tippy.js, consider how little code you actually need.
This guide walks through lightweight tooltip patterns in React—from the simplest native approach to minimal custom hooks to headless tooltip libraries like Floating UI. Each step adds capability without bloating your bundle.
Key Takeaways
- The native
titleattribute provides zero-cost tooltips but lacks styling and keyboard support - CSS-only tooltips offer styling and focus states without JavaScript, but cannot handle viewport collision
- A minimal custom hook gives programmatic control while keeping bundle size small
- Floating UI provides collision detection and viewport awareness in approximately 3kB
- Always prioritize accessibility by using proper ARIA attributes and never placing critical information only in tooltips
Start With the Native Title Attribute
The browser’s built-in title attribute is the zero-JavaScript baseline:
<button title="Save your changes">💾</button>
This works, but it’s limited. The tooltip appears after a delay, you can’t style it, and it doesn’t show on keyboard focus. Still, for truly simple cases, it costs nothing.
CSS-Only React Tooltips
For styled tooltips without JavaScript logic, CSS handles basic hover states:
function IconButton({ label, children }) {
return (
<button className="tooltip-trigger" aria-describedby="tooltip-id">
{children}
<span className="tooltip" id="tooltip-id" role="tooltip">{label}</span>
</button>
)
}
.tooltip-trigger {
position: relative;
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.tooltip-trigger:hover .tooltip,
.tooltip-trigger:focus .tooltip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: none;
}
}
This CSS-only pattern respects prefers-reduced-motion and triggers on both hover and focus. For React tooltip accessibility, the role="tooltip" and aria-label on the button provide screen reader context.
The limitation? No collision detection. If the tooltip sits near a viewport edge, it clips.
A Minimal Custom Hook
When you need programmatic control without a library, a small hook works well:
import { useState, useCallback } from 'react'
function useTooltip() {
const [isOpen, setIsOpen] = useState(false)
const triggerProps = {
onMouseEnter: useCallback(() => setIsOpen(true), []),
onMouseLeave: useCallback(() => setIsOpen(false), []),
onFocus: useCallback(() => setIsOpen(true), []),
onBlur: useCallback(() => setIsOpen(false), []),
}
return { isOpen, triggerProps }
}
Usage:
function TooltipButton({ hint, children }) {
const { isOpen, triggerProps } = useTooltip()
const id = `tooltip-${React.useId()}`
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<button {...triggerProps} aria-describedby={isOpen ? id : undefined}>
{children}
</button>
{isOpen && (
<span id={id} role="tooltip" className="tooltip">
{hint}
</span>
)}
</span>
)
}
The aria-describedby relationship connects the button to its tooltip for assistive technology. This matters: tooltips should supplement, not replace, accessible labels. Never put critical information only in a tooltip.
Discover how at OpenReplay.com.
Headless Tooltip Libraries: Floating UI
When you need proper positioning—collision detection, flipping, shifting—Floating UI is the modern choice. It’s the successor to Popper.js and weighs around 3kB.
Floating UI React tooltips give you primitives without opinions on styling:
import {
useFloating,
offset,
flip,
shift,
useHover,
useFocus,
useInteractions,
} from '@floating-ui/react'
import { useState } from 'react'
function Tooltip({ label, children }) {
const [isOpen, setIsOpen] = useState(false)
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(6), flip(), shift()],
})
const hover = useHover(context)
const focus = useFocus(context)
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus])
return (
<>
<span ref={refs.setReference} {...getReferenceProps()}>
{children}
</span>
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
role="tooltip"
{...getFloatingProps()}
>
{label}
</div>
)}
</>
)
}
This handles viewport boundaries automatically. The flip middleware repositions when space runs out, while shift keeps the tooltip visible along the axis.
For SSR safety, Floating UI’s hooks don’t access window or document during render. If you’re building a custom solution, guard those references in useEffect or useLayoutEffect.
When to Use Portals
If your tooltip’s parent has overflow: hidden or complex stacking contexts, render the tooltip in a portal:
import { createPortal } from 'react-dom'
{isOpen && createPortal(
<div ref={refs.setFloating} style={floatingStyles} role="tooltip">
{label}
</div>,
document.body
)}
This escapes clipping containers. Floating UI handles positioning regardless of where the tooltip renders in the DOM.
Why Not Tippy.js?
Tippy.js and react-popper served well for years, but they’re effectively legacy. Floating UI offers the same positioning engine with a smaller footprint and better React integration through hooks. For new projects, headless tooltip libraries like Floating UI or Radix UI Tooltip are the practical choice.
Choosing Your Approach
Match complexity to need:
- Native
title: Zero cost, zero control - CSS-only: Styled, accessible, no positioning logic
- Custom hook: Full control, minimal code, manual positioning
- Floating UI: Collision detection, viewport awareness, ~3kB
Conclusion
Tooltips don’t need to be heavy. Start simple, add capability when the use case demands it, and keep accessibility at the center of every implementation. The native title attribute works for basic cases, CSS-only solutions handle styling needs, and when you need collision detection, Floating UI delivers without the bloat of legacy libraries.
FAQs
Use aria-describedby to connect the trigger element to the tooltip content. Add role tooltip to the tooltip element itself. Keep tooltip text concise and supplementary. Never place essential information only in tooltips since users who rely on keyboards or screen readers may miss delayed or hover-only content.
CSS-only tooltips lack collision detection. The tooltip positions itself relative to its parent without checking viewport boundaries. Use Floating UI with the flip and shift middleware to automatically reposition tooltips when they would otherwise clip. Alternatively render tooltips in a portal to escape overflow hidden containers.
Floating UI is the recommended choice for new projects. It is the successor to Popper.js which powered Tippy.js and offers a smaller bundle size around 3kB with better React integration through hooks. Tippy.js still works but is considered legacy and carries more weight than modern alternatives.
Use portals when tooltip parents have overflow hidden, overflow scroll, or complex stacking contexts that cause clipping. Portals render the tooltip directly in document.body escaping any container restrictions. Floating UI handles positioning correctly regardless of where the tooltip appears in the DOM tree.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.