Skip to main content

Floating UI vs Tippy.js vs Radix Tooltip: Popover Positioning 2026

·PkgPulse Team

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/react adds 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-dom under 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

FeatureFloating UITippy.jsRadix Tooltip/Popover
FrameworkAny (React, Vue, Svelte)Any + ReactReact only
StylingUnstyled (bring your own)Styled (override available)Unstyled (bring your own)
AccessibilityManual (you implement)Basic✅ WAI-ARIA compliant
Focus trapFloatingFocusManagerNo✅ Built-in
Keyboard navVia hooksBasic✅ Built-in
Collision detection✅ Advanced✅ Via Popper.js✅ Via Floating UI
Arrow positioningFloatingArrow✅ Built-inTooltip.Arrow
AnimationsCSS (you define)✅ Built-in themesCSS data-state
PortalFloatingPortal✅ AutoPortal
Virtual elementsLimitedNo
Bundle size~10kB~15kB~8kB per primitive
npm weekly12M3M8M (tooltip)
GitHub stars29k11k22k (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 asChild pattern 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.