EmpireUI
Get Pro
← Blog8 min read#tooltip#react#floating-ui

Tooltip in React: Floating-UI, Radix and Pure CSS Approaches

Three real approaches to React tooltips — pure CSS, Radix UI, and Floating-UI — with code, a11y gotchas, and when to reach for each one.

Developer working on a React component UI on a laptop screen

Why Tooltips Are Harder Than They Look

You'd think a tooltip would be simple — show some text when the user hovers a thing. It's not. The moment you try to build one from scratch in React, you run into scroll containers that clip your popover, keyboard users who can't trigger it, screen readers that ignore it entirely, and focus traps that eat your users alive. All of this from a little floating label.

Honestly, the number of tooltip implementations I've seen in production codebases that fail at least two of those four problems is embarrassing. We're in 2026 and people are still using title attributes and calling it done. Don't do that.

This article walks through three real approaches in order of increasing power: pure CSS, Radix UI Tooltip, and Floating-UI. Each has its place. Pick based on what your project actually needs, not what's trending on Twitter.

Pure CSS Tooltip: Fast, Zero Dependencies, Limited

If your tooltip is decorative and you don't care about programmatic control, CSS-only is completely fine. You get zero JavaScript, zero bundle impact, and it renders immediately. The limitation is real though — CSS can't easily react to scroll container boundaries, and you lose control over placement logic beyond a few hardcoded offsets.

Here's a working implementation that relies on data-tooltip and a pseudo-element. It positions 8px above the trigger by default and handles keyboard focus via :focus-visible:

[data-tooltip] {
  position: relative;
  cursor: default;
}

[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  background: #111827;
  color: #f9fafb;
  font-size: 0.75rem;
  line-height: 1.4;
  padding: 4px 8px;
  border-radius: 4px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
  z-index: 50;
}

[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
  opacity: 1;
}

Usage is just <button data-tooltip="Copy to clipboard">Copy</button>. Worth noting: you still need role="tooltip" and aria-describedby wiring if you want real screen reader support — CSS alone won't get you there. That said, for decorative labels on icon buttons inside a design-heavy UI (say, something pulled from Empire UI), this approach is totally reasonable.

Radix UI Tooltip: Accessible by Default, Opinionated Structure

Radix @radix-ui/react-tooltip (v1.x as of writing) handles the hard accessibility stuff for you — aria-describedby wiring, keyboard triggering via focus, escape-key dismiss, and delay management. It's opinionated about structure but flexible about styling.

Quick aside: Radix uses a provider pattern, so you need <TooltipProvider> near the top of your tree. Miss that and your tooltips will silently do nothing. That's a fun debugging session the first time.

import * as Tooltip from '@radix-ui/react-tooltip';

export function CopyButton() {
  return (
    <Tooltip.Provider delayDuration={300}>
      <Tooltip.Root>
        <Tooltip.Trigger asChild>
          <button className="icon-btn" aria-label="Copy">
            <CopyIcon />
          </button>
        </Tooltip.Trigger>
        <Tooltip.Portal>
          <Tooltip.Content
            className="tooltip-content"
            sideOffset={8}
            side="top"
          >
            Copy to clipboard
            <Tooltip.Arrow className="tooltip-arrow" />
          </Tooltip.Content>
        </Tooltip.Portal>
      </Tooltip.Root>
    </Tooltip.Provider>
  );
}

The sideOffset={8} is your 8px gap between trigger and content — same visual result as the CSS version, but now Radix also flips sides automatically when you're near a viewport edge. asChild lets you attach to any element without an extra wrapper div in the DOM. In practice, the Radix approach pays for itself the moment a QA person runs a screen reader over your app.

Floating-UI: Maximum Control, Maximum Responsibility

Floating-UI (@floating-ui/react, formerly Popper.js) is the lower-level primitive that libraries like Radix actually build on top of. You'd reach for it when you need behaviour that Radix doesn't expose — custom flip logic, middleware that clamps to a specific scroll container, or a tooltip that doubles as a popover with interactive content.

Look, the API is not small. You're composing middleware manually and managing open state yourself. But the positioning power is genuinely impressive — it accounts for scroll, clipping ancestors, and viewport boundaries in a way that's very hard to reproduce with pure CSS.

import {
  useFloating,
  autoUpdate,
  offset,
  flip,
  shift,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  useInteractions,
  FloatingPortal,
} from '@floating-ui/react';
import { useState } from 'react';

export function FloatingTooltip({ label, children }) {
  const [open, setOpen] = useState(false);

  const { refs, floatingStyles, context } = useFloating({
    open,
    onOpenChange: setOpen,
    placement: 'top',
    whileElementsMounted: autoUpdate,
    middleware: [offset(8), flip(), shift({ padding: 8 })],
  });

  const hover = useHover(context, { move: false });
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: 'tooltip' });

  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover, focus, dismiss, role,
  ]);

  return (
    <>
      <span ref={refs.setReference} {...getReferenceProps()}>
        {children}
      </span>
      {open && (
        <FloatingPortal>
          <div
            ref={refs.setFloating}
            style={floatingStyles}
            className="tooltip-content"
            {...getFloatingProps()}
          >
            {label}
          </div>
        </FloatingPortal>
      )}
    </>
  );
}

One more thing — autoUpdate is crucial here: it re-runs positioning whenever the reference element moves (scroll, resize, DOM mutation). Without it your tooltip stays stuck in its original position as the page shifts around it. The shift middleware with padding: 8 prevents the tooltip from kissing the viewport edge, which looks bad at 375px wide mobile screens.

Accessibility Requirements You Can't Skip

The WCAG 1.4.13 criterion specifically addresses hover and focus content. Your tooltip must be dismissible (Escape key), hoverable (pointer can move onto the tooltip without it disappearing), and persistent (it doesn't vanish after a timeout unless the user dismisses it). Radix and Floating-UI handle all three by default. Pure CSS handles none of them.

Screen readers need an aria-describedby relationship between your trigger and the tooltip content. The id on the tooltip element has to match. Radix wires this automatically. With Floating-UI you get it via useRole(context, { role: 'tooltip' }) — that hook stamps the right ARIA attributes onto both elements.

What about touch devices? Tooltips on mobile are a UX trap anyway. If information is important enough to surface, it should be visible without a hover state. That said, if you must have them, both Radix and Floating-UI handle touch via long-press when configured correctly.

Styling Your Tooltip to Match Your Design System

All three approaches are headless or near-headless — you supply the CSS. If you're building something with a glassmorphism aesthetic, check out the glassmorphism components section for ready-to-use blur and border treatments you can drop straight onto .tooltip-content.

For a quick baseline, you want at minimum: max-width: 240px, word wrapping, a sensible z-index (50 or higher), and a subtle shadow. Using CSS custom properties makes theming trivial across light and dark modes:

.tooltip-content {
  background: var(--tooltip-bg, #111827);
  color: var(--tooltip-color, #f9fafb);
  font-size: 0.75rem;
  padding: 6px 10px;
  border-radius: 6px;
  max-width: 240px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
  z-index: 50;
  animation: tooltip-in 0.12s ease;
}

@keyframes tooltip-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}

If you want to go further with motion — spring physics, GSAP, Framer Motion — that's a separate topic, but the gradient generator and box shadow generator tools are handy for getting the visual values right before you even touch code.

Which Approach Should You Actually Use?

Here's the honest decision tree. If it's a one-off decorative tooltip in a marketing page and you have no budget for extra dependencies — pure CSS. If you're building a product UI that needs real accessibility and you don't want to think too hard about positioning edge cases — Radix. If you need complex positioning, interactive content inside the tooltip, or you're already managing your own floating layer for other things — Floating-UI.

That said, Radix and Floating-UI are not mutually exclusive in a codebase. Use Radix for standard tooltips and Floating-UI for your custom dropdown menu — that's a normal setup. Both packages are tree-shakeable and your users won't notice the difference.

In practice, I'd say 80% of React projects need exactly what Radix provides. The remaining 20% need Floating-UI primitives, and almost nobody should be rolling pure-CSS tooltips in a real app unless the context is genuinely decorative. Browse the Empire UI component library to see styled tooltip examples that work with all three approaches out of the box.

FAQ

What's the difference between Floating-UI and Radix UI Tooltip?

Radix is a full accessible component built on top of Floating-UI (or its own positioning logic). Floating-UI is the low-level positioning primitive — you compose it yourself. Use Radix unless you need custom behaviour Radix doesn't expose.

Does a pure CSS tooltip pass WCAG accessibility requirements?

Not fully. Pure CSS can't satisfy WCAG 1.4.13 requirements around dismissibility and hover persistence without JavaScript. It's fine for decorative labels, not for content that users need to read or interact with.

How do I stop my tooltip from being clipped by an overflow: hidden parent?

Render the tooltip into a portal — either <FloatingPortal> from Floating-UI or <Tooltip.Portal> from Radix. That moves the DOM node to document.body, outside any clipping ancestors.

Should I show tooltips on mobile touch devices?

Generally no — if the information matters, it should be visible without hover. If you must, Radix supports a disableHoverableContent prop and Floating-UI supports touch via long-press configuration, but consider a visible label instead.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Context Menu (Right-Click) in React: Radix, Custom and AccessibleDropdown Menu in React: Accessible, Animated, Keyboard-ReadyFrosted Glass Tooltip: Accessible Popover with Blur Effectshadcn/ui vs Radix UI: What's the Difference, Which Do You Need?