Floating UI vs Tippy.js vs Radix Tooltip: Popover Positioning 2026
Floating UI vs Tippy.js vs Radix Tooltip: Popover Positioning 2026
TL;DR
Building tooltips, popovers, dropdowns, and floating menus correctly is deceptively hard — viewport overflow, collision detection, scroll containers, and keyboard accessibility are all gotchas that custom solutions routinely miss. Floating UI (successor to Popper.js from the same authors) is the low-level positioning engine — pure geometry and collision detection, totally unstyled, works with any framework, and is what Radix, Mantine, and many others use internally. Tippy.js is the batteries-included tooltip library built on Popper.js — styled out of the box, declarative API, animates, works in vanilla JS and React — but it's showing its age in 2026 with no App Router support and weaker accessibility guarantees. Radix UI's Tooltip and Popover are headless, fully accessible (WAI-ARIA compliant), React-only components built on Floating UI internally — the correct choice for React/Next.js component libraries where accessibility is non-negotiable. For low-level control over positioning in any framework: Floating UI. For quick tooltips with minimal config: Tippy.js. For production React UIs that must be accessible: Radix Tooltip/Popover.
Key Takeaways
- Floating UI is framework-agnostic — core is vanilla JS,
@floating-ui/reactadds React hooks - Floating UI handles all edge cases — viewport overflow, flip, shift, arrow, virtual elements
- Tippy.js is easiest to get started —
<Tippy content="Tooltip">wraps any element - Radix Tooltip is fully WAI-ARIA compliant — focus management, screen readers, keyboard nav
- Tippy.js is built on Popper.js — Floating UI's predecessor, still maintained but less active
- Radix Popover manages open state — controlled and uncontrolled modes, portal rendering
- Floating UI powers Radix internally — Radix uses
@floating-ui/react-domunder the hood
Use Case Map
Simple tooltip on hover → Tippy.js or Radix Tooltip
Tooltip with custom render → Floating UI or Radix Tooltip
Accessible popover with content → Radix Popover
Dropdown menu with keyboard nav → Radix DropdownMenu
Custom positioning engine → Floating UI (raw)
Framework-agnostic tooltip → Tippy.js or Floating UI
Select/Combobox overlay → Floating UI or Radix Select
Context menu (right-click) → Radix ContextMenu
Floating UI: The Positioning Engine
Floating UI provides the geometry and collision detection algorithms — you wire up the DOM refs and React state yourself.
Installation
npm install @floating-ui/react
# For vanilla JS (no React):
npm install @floating-ui/dom
Basic Tooltip
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
} from "@floating-ui/react";
import { useState } from "react";
interface TooltipProps {
content: string;
children: React.ReactElement;
}
export function Tooltip({ content, children }: TooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: "top",
// Keep in sync with scroll and resize
whileElementsMounted: autoUpdate,
middleware: [
offset(8), // Distance from reference
flip(), // Flip to bottom if no space above
shift({ padding: 8 }), // Shift horizontally to stay in viewport
],
});
// Interaction hooks — compose behaviors
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 (
<>
{/* Attach to trigger element */}
{React.cloneElement(children, {
ref: refs.setReference,
...getReferenceProps(),
})}
{/* Tooltip — rendered in portal to escape stacking contexts */}
<FloatingPortal>
{isOpen && (
<div
ref={refs.setFloating}
style={{
...floatingStyles,
background: "#1a1a1a",
color: "#fff",
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
zIndex: 9999,
}}
{...getFloatingProps()}
>
{content}
</div>
)}
</FloatingPortal>
</>
);
}
// Usage
<Tooltip content="Copy to clipboard">
<button>Copy</button>
</Tooltip>
Arrow Placement
import {
useFloating,
arrow,
offset,
flip,
FloatingArrow,
} from "@floating-ui/react";
import { useRef } from "react";
export function TooltipWithArrow({ content, children }: TooltipProps) {
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context, middlewareData, placement } = useFloating({
middleware: [
offset(10),
flip(),
arrow({ element: arrowRef }),
],
});
return (
<>
<div ref={refs.setReference}>{children}</div>
<div ref={refs.setFloating} style={floatingStyles}>
{content}
{/* FloatingArrow renders an SVG arrow positioned correctly */}
<FloatingArrow
ref={arrowRef}
context={context}
fill="#1a1a1a"
height={8}
width={14}
/>
</div>
</>
);
}
Popover (Click-to-Open)
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingFocusManager,
} from "@floating-ui/react";
import { useState } from "react";
export function Popover({ trigger, content }: { trigger: React.ReactNode; content: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: "bottom-start",
whileElementsMounted: autoUpdate,
middleware: [offset(4), flip(), shift({ padding: 8 })],
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "dialog" });
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
{trigger}
</div>
<FloatingPortal>
{isOpen && (
// FloatingFocusManager traps focus inside the popover
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={{
...floatingStyles,
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 8,
boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
padding: 16,
zIndex: 9999,
minWidth: 200,
}}
{...getFloatingProps()}
>
{content}
</div>
</FloatingFocusManager>
)}
</FloatingPortal>
</>
);
}
Virtual Element (Context Menu)
import { useFloating, offset, flip, shift, useClientPoint, useInteractions } from "@floating-ui/react";
import { useState } from "react";
export function ContextMenu({ items }: { items: string[] }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: "bottom-start",
middleware: [offset({ mainAxis: 5, alignmentAxis: 4 }), flip(), shift()],
});
// Follow the mouse cursor
const clientPoint = useClientPoint(context);
const { getReferenceProps, getFloatingProps } = useInteractions([clientPoint]);
return (
<div
ref={refs.setReference}
onContextMenu={(e) => {
e.preventDefault();
setIsOpen(true);
}}
style={{ minHeight: 200, border: "1px dashed #ccc", padding: 16 }}
{...getReferenceProps()}
>
Right-click anywhere here
{isOpen && (
<div
ref={refs.setFloating}
style={{
...floatingStyles,
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 6,
boxShadow: "0 2px 10px rgba(0,0,0,0.12)",
zIndex: 9999,
}}
{...getFloatingProps()}
>
{items.map((item) => (
<button
key={item}
style={{ display: "block", width: "100%", padding: "8px 16px", textAlign: "left" }}
onClick={() => setIsOpen(false)}
>
{item}
</button>
))}
</div>
)}
</div>
);
}
Tippy.js: Batteries-Included Tooltips
Tippy.js provides a complete tooltip and popover solution with themes, animations, and a declarative API — minimal configuration required.
Installation
npm install tippy.js @tippyjs/react
Basic Usage
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css"; // Default theme
export function CopyButton() {
return (
<Tippy content="Copy to clipboard">
<button onClick={() => navigator.clipboard.writeText("text")}>
Copy
</button>
</Tippy>
);
}
Placement and Options
import Tippy from "@tippyjs/react";
export function FeatureTooltips() {
return (
<div>
<Tippy content="Shows above" placement="top">
<button>Top</button>
</Tippy>
<Tippy content="Shows on the right" placement="right">
<button>Right</button>
</Tippy>
{/* Delay: 300ms show, 100ms hide */}
<Tippy content="Delayed tooltip" delay={[300, 100]}>
<button>Delayed</button>
</Tippy>
{/* Click to toggle instead of hover */}
<Tippy content="Click me" trigger="click" interactive>
<button>Click</button>
</Tippy>
{/* Interactive (won't close when hovering tooltip) */}
<Tippy
content={
<div>
<strong>Rich content</strong>
<p>With multiple elements</p>
<a href="/docs">Read more</a>
</div>
}
interactive
interactiveBorder={20}
placement="bottom"
>
<button>Hover for rich tooltip</button>
</Tippy>
{/* Disabled */}
<Tippy content="Tooltip" disabled={false}>
<span>
<button disabled>Disabled Button</button>
</span>
</Tippy>
</div>
);
}
Animations and Themes
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import "tippy.js/animations/scale.css";
import "tippy.js/themes/light.css";
import "tippy.js/themes/material.css";
export function ThemedTooltips() {
return (
<>
{/* Built-in light theme */}
<Tippy content="Light theme" theme="light">
<button>Light</button>
</Tippy>
{/* Scale animation */}
<Tippy content="Animated" animation="scale">
<button>Scale</button>
</Tippy>
{/* Custom theme via CSS */}
<Tippy content="Custom theme" className="custom-tippy">
<button>Custom</button>
</Tippy>
</>
);
}
Controlled Tippy
import Tippy from "@tippyjs/react";
import { useState } from "react";
export function ControlledTooltip() {
const [visible, setVisible] = useState(false);
return (
<Tippy
content="This is controlled"
visible={visible}
onClickOutside={() => setVisible(false)}
interactive
>
<button onClick={() => setVisible((v) => !v)}>
{visible ? "Hide" : "Show"} Tooltip
</button>
</Tippy>
);
}
Radix UI Tooltip and Popover: Accessible Components
Radix provides fully accessible, headless components with correct ARIA roles, focus management, and keyboard navigation — you bring your own styles.
Installation
npm install @radix-ui/react-tooltip @radix-ui/react-popover
Tooltip
import * as Tooltip from "@radix-ui/react-tooltip";
// Provider wraps your app — controls delay behavior globally
export function App() {
return (
<Tooltip.Provider delayDuration={300} skipDelayDuration={500}>
<YourApp />
</Tooltip.Provider>
);
}
// Individual tooltip
export function DeleteButton() {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button className="icon-button" aria-label="Delete item">
🗑️
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="tooltip-content"
sideOffset={4}
side="top"
align="center"
>
Delete item
<Tooltip.Arrow className="tooltip-arrow" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}
// CSS
/*
.tooltip-content {
background: #1a1a1a;
color: white;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
animation-duration: 150ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.tooltip-content[data-state='delayed-open'][data-side='top'] {
animation-name: slideDownAndFade;
}
@keyframes slideDownAndFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
.tooltip-arrow {
fill: #1a1a1a;
}
*/
Popover
import * as Popover from "@radix-ui/react-popover";
export function FilterPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button className="filter-button">Filters ⚙️</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="popover-content"
sideOffset={4}
align="start"
// Prevent closing when focus moves inside popover
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="filter-form">
<h3>Filter Options</h3>
<label>
Status
<select>
<option>All</option>
<option>Active</option>
<option>Inactive</option>
</select>
</label>
<label>
Date Range
<input type="date" />
</label>
<div className="filter-actions">
<button>Reset</button>
<Popover.Close asChild>
<button>Apply</button>
</Popover.Close>
</div>
</div>
<Popover.Arrow className="popover-arrow" />
<Popover.Close className="popover-close" aria-label="Close">
✕
</Popover.Close>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
Tooltip with Tailwind (shadcn/ui Pattern)
// components/ui/tooltip.tsx — shadcn/ui Tooltip component
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const TooltipRoot = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
// Export the Tooltip component
export function Tooltip({
children,
content,
...props
}: {
children: React.ReactNode;
content: React.ReactNode;
} & React.ComponentPropsWithoutRef<typeof TooltipRoot>) {
return (
<TooltipProvider>
<TooltipRoot {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}
// Usage with Tailwind
<Tooltip content="Settings">
<button>⚙️</button>
</Tooltip>
Feature Comparison
| Feature | Floating UI | Tippy.js | Radix Tooltip/Popover |
|---|---|---|---|
| Framework | Any (React, Vue, Svelte) | Any + React | React only |
| Styling | Unstyled (bring your own) | Styled (override available) | Unstyled (bring your own) |
| Accessibility | Manual (you implement) | Basic | ✅ WAI-ARIA compliant |
| Focus trap | FloatingFocusManager | No | ✅ Built-in |
| Keyboard nav | Via hooks | Basic | ✅ Built-in |
| Collision detection | ✅ Advanced | ✅ Via Popper.js | ✅ Via Floating UI |
| Arrow positioning | ✅ FloatingArrow | ✅ Built-in | ✅ Tooltip.Arrow |
| Animations | CSS (you define) | ✅ Built-in themes | CSS data-state |
| Portal | ✅ FloatingPortal | ✅ Auto | ✅ Portal |
| Virtual elements | ✅ | Limited | No |
| Bundle size | ~10kB | ~15kB | ~8kB per primitive |
| npm weekly | 12M | 3M | 8M (tooltip) |
| GitHub stars | 29k | 11k | 22k (radix-ui/primitives) |
| TypeScript | ✅ Full | ✅ | ✅ Full |
When to Use Each
Choose Floating UI if:
- Building a component library from scratch (unstyled primitives)
- Need maximum control over positioning behavior and styling
- Framework-agnostic — Vue, Svelte, vanilla JS, or React
- Virtual element positioning (context menus, cursors)
- Complex middleware requirements (custom offset logic)
- Want to understand exactly what's happening — no magic
Choose Tippy.js if:
- Quick tooltip needed with minimal setup
- Vanilla JS project or legacy codebase
- Want built-in themes and animations without CSS work
- Simple hover tooltips where accessibility is secondary
- Prototyping or internal tools where ARIA isn't critical
Choose Radix Tooltip/Popover if:
- React/Next.js production application
- Accessibility is required — screen readers, keyboard navigation
- Using shadcn/ui (Radix is the foundation)
- Want compound component API with proper focus management
- Need
asChildpattern to avoid extra DOM elements - Building a design system where consumers control all styling
Methodology
Data sourced from Floating UI documentation (floating-ui.com/docs), Tippy.js documentation (atomiks.github.io/tippyjs), Radix UI documentation (radix-ui.com/docs), npm weekly download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the React Discord, CSS-Tricks, and web accessibility forums.
Related: Radix UI vs Headless UI vs Ariakit for broader headless component comparisons, or shadcn/ui vs Mantine vs Chakra UI for styled React component libraries.