All posts
22 Sep 2026

Hovering in the Dark: Crafting an Accessible Tooltip & Popover System in React

A comprehensive step-by-step technical guide on building a production-ready, accessible tooltip and popover component system in React and TypeScript using Floating UI.

Hovering in the Dark: Crafting an Accessible Tooltip & Popover System in React

Tooltips and popovers seem deceptively simple. You hover over a button, a little box of text appears. You click a profile icon, a menu pops open. Easy, right?

Not quite. When you pull back the curtain, standard UI tooltips and popovers are a minefield of edge cases: viewport overflows, z-index wars, screen reader blindness, focus trapping failures, and lack of keyboard navigation.

In this deep dive, we will build a production-ready, highly accessible Tooltip and Popover component system in React and TypeScript. We will leverage Floating UI for robust anchor positioning and collision detection, while hand-crafting our accessibility (a11y) layer to ensure screen readers and keyboard-only users are treated as first-class citizens.


The Architecture: Tooltips vs. Popovers

Before writing code, let’s define our terms because they dictate our accessibility and interaction patterns:

  1. Tooltips: Brief, informative hints triggered by hover or focus. They describe an element. They are non-interactive (no links or buttons inside), disappear when the mouse leaves or focus shifts, and map to role="tooltip" using aria-describedby.
  2. Popovers: Rich content containers triggered by click. They can contain interactive elements (forms, buttons, links), require explicit dismissal (like pressing Escape or clicking outside), trap or manage focus, and map to aria-expanded and dialog/menu semantics.

Because they share underlying mechanics—positioning, collision detection, and portal management—we can build a unified primitive using Floating UI and specialize them via React composition.


Step 1: Setting Up the Dependencies

We need @floating-ui/react, the premier library for handling floating elements. It provides hooks for positioning, interaction handling (hover, click, dismiss), and accessibility out of the box.

bash
npm install @floating-ui/react
npm install -D typescript @types/react

Step 2: Crafting the Core Hook

Let’s create a custom hook, usePopoverSystem, that encapsulates Floating UI logic, state management, and accessibility bindings. This keeps our components clean and separates behavior from presentation.

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

interface UsePopoverOptions {
  initialOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  placement?: 'top' | 'bottom' | 'left' | 'right';
  strategy?: 'tooltip' | 'popover';
}

export function usePopoverSystem({
  initialOpen = false,
  onOpenChange,
  placement = 'top',
  strategy = 'tooltip',
}: UsePopoverOptions) {
  const [isOpen, setIsOpen] = useState(initialOpen);

  const handleOpenChange = (open: boolean) => {
    setIsOpen(open);
    onOpenChange?.(open);
  };

  const { refs, floatingStyles, context } = useFloating({
    open: isOpen,
    onOpenChange: handleOpenChange,
    placement,
    whileElementsMounted: autoUpdate,
    middleware: [
      offset(8),
      flip({
        fallbackAxisSideDirection: 'start',
        crossAxis: false,
      }),
      shift({ padding: 8 }),
    ],
  });

  // Interaction hooks based on strategy
  const hover = useHover(context, {
    enabled: strategy === 'tooltip',
    handleClose: safePolygon(),
  });
  
  const click = useClick(context, {
    enabled: strategy === 'popover',
  });
  
  const dismiss = useDismiss(context);
  const role = useRole(context, {
    role: strategy === 'tooltip' ? 'tooltip' : 'dialog',
  });

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

  return {
    isOpen,
    refs,
    floatingStyles,
    context,
    getReferenceProps,
    getFloatingProps,
  };
}

Why safePolygon()?

If you have ever hovered over a tooltip and tried to move your mouse directly into the tooltip content only for it to vanish instantly, you’ve experienced a broken hover buffer. Floating UI’s safePolygon() generates an invisible safety triangle that allows the mouse to bridge the gap between the reference element and the floating content seamlessly.


Step 3: Building the Tooltip Component

Now, let’s assemble the Tooltip component using React Context to share state between the trigger and the floating panel without prop drilling.

import React, { createContext, useContext, useId, useMemo } from 'react';
import { usePopoverSystem } from './usePopoverSystem';
import { FloatingPortal } from '@floating-ui/react';

interface TooltipContextType {
  getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
  getFloatingProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
  refs: any;
  floatingStyles: React.CSSProperties;
  isOpen: boolean;
  labelId: string;
}

const TooltipContext = createContext<TooltipContextType | null>(null);

export const Tooltip: React.FC<{ children: React.ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right' }> = ({
  children,
  placement = 'top',
})
 => {
  const labelId = useId();
  const system = usePopoverSystem({ placement, strategy: 'tooltip' });

  const value = useMemo(
    () => ({ ...system, labelId }),
    [system, labelId]
  );

  return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
};

Creating Trigger and Content Subcomponents

export const TooltipTrigger: React.FC<{ children: React.ReactNode; asChild?: boolean }> = ({
  children,
  asChild = false,
}) => {
  const context = useContext(TooltipContext);
  if (!context) throw new Error('TooltipTrigger must be used within a Tooltip');

  const { refs, getReferenceProps, labelId, isOpen } = context;

  // Basic clone element approach for child triggers
  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(
      children,
      getReferenceProps({
        ref: refs.setReference,
        'aria-describedby': isOpen ? labelId : undefined,
        ...(children.props as any),
      })
    );
  }

  return (
    <button
      ref={refs.setReference}
      aria-describedby={isOpen ? labelId : undefined}
      {...getReferenceProps()}
      type="button"
    >
      {children}
    </button>
  );
};

export const TooltipContent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const context = useContext(TooltipContext);
  if (!context) throw new Error('TooltipContent must be used within a Tooltip');

  const { refs, floatingStyles, getFloatingProps, isOpen, labelId } = context;

  if (!isOpen) return null;

  return (
    <FloatingPortal>
      <div
        ref={refs.setFloating}
        style={floatingStyles}
        id={labelId}
        role="tooltip"
        {...getFloatingProps()}
        className="z-50 px-3 py-1.5 text-xs text-white bg-slate-900 rounded shadow-lg animate-in fade-in-0 zoom-in-95"
      >
        {children}
      </div>
    </FloatingPortal>
  );
};

Step 4: Building the Popover Component

Popovers differ from tooltips because they handle clicks, contain rich interactive DOM trees, and require robust focus management. Let’s build our Popover component using the same foundation.

import React, { createContext, useContext, useId, useMemo } from 'react';
import { usePopoverSystem } from './usePopoverSystem';
import { FloatingPortal, useDismiss, useFocusRing, useRole } from '@floating-ui/react';

const PopoverContext = createContext<any>(null);

export const Popover: React.FC<{ children: React.ReactNode; placement?: 'bottom' | 'top' | 'left' | 'right' }> = ({
  children,
  placement = 'bottom',
}) => {
  const labelId = useId();
  const descriptionId = useId();
  const system = usePopoverSystem({ placement, strategy: 'popover' });

  const value = useMemo(
    () => ({ ...system, labelId, descriptionId }),
    [system, labelId, descriptionId]
  );

  return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
};

export const PopoverTrigger: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const { refs, getReferenceProps, isOpen } = useContext(PopoverContext);

  return (
    <button
      ref={refs.setReference}
      aria-expanded={isOpen}
      type="button"
      {...getReferenceProps()}
    >
      {children}
    </button>
  );
};

export const PopoverContent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const { refs, floatingStyles, getFloatingProps, isOpen, labelId } = useContext(PopoverContext);

  if (!isOpen) return null;

  return (
    <FloatingPortal>
      <div
        ref={refs.setFloating}
        style={floatingStyles}
        aria-labelledby={labelId}
        tabIndex={-1}
        {...getFloatingProps()}
        className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-lg shadow-xl text-slate-900 focus:outline-none"
      >
        {children}
      </div>
    </FloatingPortal>
  );
};

Step 5: Tackling Accessibility & Edge Cases

Building components that look right is only half the battle. Let’s audit our implementation against core accessibility requirements:

1. Screen Reader Association (aria-describedby vs aria-expanded)

  • Tooltips must expose aria-describedby pointing to the tooltip’s unique ID only when open. This prevents screen readers from reading extraneous helper text continuously while the user navigates past the button.
  • Popovers must expose aria-expanded="true|false" to announce state changes to assistive technology.

2. Viewport Collisions & Overflow

Using manual absolute positioning often causes tooltips to clip off screen edges. By integrating Floating UI’s flip() and shift() middleware:

  • flip() automatically re-orients the floating element to the opposite side (e.g., flipping from top to bottom) if there is insufficient vertical viewport space.
  • shift() nudges the element horizontally or vertically so it stays completely inside the visible viewport bounds with padding.

3. Focus Trapping and Keyboard Dismissal

  • Pressing Escape closes both tooltips and popovers automatically via useDismiss.
  • When a Popover opens, focus should gracefully handle returning to the trigger element when closed. Floating UI manages this cleanly by storing the active element reference prior to opening.

Putting It Together: Usage Example

Here is how clean, readable, and expressive our component APIs become in consumer code:

export function App() {
  return (
    <div className="p-12 flex gap-8 items-center">
      {/* Tooltip Example */}
      <Tooltip placement="top">
        <TooltipTrigger>
          <span className="underline decoration-dotted cursor-help">Hover me</span>
        </TooltipTrigger>
        <TooltipContent>
          This is a helpful, accessible tooltip description.
        </TooltipContent>
      </Tooltip>

      {/* Popover Example */}
      <Popover placement="bottom">
        <PopoverTrigger>
          <span className="px-4 py-2 bg-blue-600 text-white rounded font-medium">
            Open Popover
          </span>
        </PopoverTrigger>
        <PopoverContent>
          <h3 className="font-bold text-sm mb-2">Account Settings</h3>
          <p className="text-xs text-slate-500 mb-4">
            Manage your preferences and notification parameters right here.
          </p>
          <button className="w-full py-1.5 bg-slate-100 hover:bg-slate-200 rounded text-xs font-semibold">
            Save Preferences
          </button>
        </PopoverContent>
      </Popover>
    </div>
  );
}

Conclusion

By pairing React Context, TypeScript, and Floating UI, we’ve built a bulletproof, accessible tooltip and popover engine. We avoided the common pitfalls of custom positioning math, secured seamless viewport edge-case handling, and guaranteed compliance with WCAG standards using proper ARIA attributes, semantic roles, and focus restoration.

Next time you’re tempted to write a quick div absolute top-0 tooltip hack, remember your users navigating via screen readers or small mobile viewports—take the extra step and build a system designed to scale.

More posts