All posts
9 Oct 2026

Hover, Focus, Escape: Building an Accessible Tooltip Component in React

{

{ “title”: “Hover, Focus, Escape: Building an Accessible Tooltip Component in React”, “summary”: “Learn how to build a production-ready, fully accessible tooltip and popover system in React using Floating UI, ARIA attributes, and robust keyboard dismissal patterns.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”], “body”: “# Hover, Focus, Escape: Building an Accessible Tooltip Component in React\n\nBuilding a tooltip looks simple on the surface: render a small box of text near a target element when the mouse enters. But once you factor in keyboard focus, screen readers, touch devices, edge-of-screen boundary collision, and the Escape key, standard CSS-only solutions or naive state implementations fall apart.\n\nIn this post, we’ll build a production-grade, fully accessible tooltip and popover component system in React from scratch using TypeScript and Floating UI.\n\n—\n\n## The Anatomy of an Accessible Tooltip\n\nBefore writing code, let’s establish what makes a tooltip accessible according to the WAI-ARIA Authoring Practices Guide (APG):\n\n1. Trigger Relationship: The trigger element must reference the tooltip using aria-describedby (for tooltips) or aria-controls (for popovers).\n2. Keyboard Accessibility: Tabbing to the trigger must open the tooltip. Tabbing away or pressing Escape must close it.\n3. Hover Parity: Moving the mouse over the trigger opens it; moving the mouse away closes it, with appropriate grace periods for moving into the tooltip content.\n4. Screen Reader Announcements: The content inside the tooltip must be read when the trigger receives focus, without altering the document layout destructively.\n5. Touch Interactivity: On touch devices, a tap should toggle the tooltip rather than relying on hover states that don’t exist.\n\n—\n\n## Setting Up Dependencies\n\nWe will use @floating-ui/react, which handles anchor positioning, collision detection, and accessibility primitives out of the box.\n\nbash\nnpm install @floating-ui/react\n\n\n—\n\n## Building the Tooltip Core\n\nLet’s create a reusable Tooltip component. We’ll separate the state management and interaction hooks provided by Floating UI into a clean, declarative API.\n\ntsx\nimport React, { useState, cloneElement, isValidElement } from 'react';\nimport {\n useFloating,\n useAutoUpdate,\n useOffset,\n useShift,\n useFlip,\n useDismiss,\n useRole,\n useHover,\n useFocus,\n useInteractions,\n FloatingPortal,\n safePolygon,\n} from '@floating-ui/react';\n\ninterface TooltipProps {\n content: React.ReactNode;\n children: React.ReactNode;\n placement?: 'top' | 'bottom' | 'left' | 'right';\n delay?: number;\n}\n\nexport const Tooltip: React.FC<TooltipProps> = ({\n content,\n children,\n placement = 'top',\n delay = 200,\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n\n const {\n refs,\n floatingStyles,\n context,\n } = useFloating({\n open: isOpen,\n onOpenChange: setIsOpen,\n placement,\n middleware: [\n useOffset(8),\n useFlip(),\n useShift({ padding: 8 }),\n ],\n });\n\n // Interaction hooks\n const hover = useHover(context, {\n delay: { open: delay, close: 100 },\n handleClose: safePolygon(), // Allows smooth cursor transition from trigger to tooltip\n });\n const focus = useFocus(context);\n const dismiss = useDismiss(context, {},\n // Dismisses on ESC key automatically\n );\n const role = useRole(context, { role: 'tooltip' });\n\n // Merge all interactions into getting helpers\n const { getReferenceProps, getFloatingProps } = useInteractions([\n hover,\n focus,\n dismiss,\n role,\n ]);\n\n if (!isValidElement(children)) {\n return null;\n }\n\n // Clone child to inject ref and ARIA attributes\n const trigger = cloneElement(children, {\n ref: refs.setReference,\n ...getReferenceProps(),\n });\n\n return (\n <>\n {trigger}\n {isOpen && (\n <FloatingPortal>\n <div\n ref={refs.setFloating}\n style={floatingStyles}\n {...getFloatingProps()}\n className=\"z-50 px-3 py-1.5 text-xs text-white bg-slate-900 rounded shadow-lg pointer-events-auto\"\n >\n {content}\n </div>\n </FloatingPortal>\n )}\n </>\n );\n};\n\n\n### Why safePolygon() Matters\n\nOne of the most common UX flaws in custom tooltips is the "mouse-escape trap." When a user moves their mouse from the trigger toward the tooltip content, the mouse briefly leaves the bounding box of the trigger element. Without safePolygon(), the tooltip closes instantly, creating a frustrating flickering effect.\n\nFloating UI’s safePolygon() calculates an invisible polygon between the trigger and the floating element, allowing the cursor to travel across the gap safely.\n\n—\n\n## Upgrading to a Popover Component\n\nWhile tooltips are meant for short, non-interactive text strings, popovers contain interactive elements like buttons, links, and forms. Popovers require a click trigger rather than a hover trigger, and different ARIA roles (dialog instead of tooltip).\n\nHere is how we adapt our Floating UI setup for a fully accessible Popover:\n\ntsx\nimport React, { useState } from 'react';\nimport {\n useFloating,\n useOffset,\n useFlip,\n useShift,\n useDismiss,\n useRole,\n useClick,\n useInteractions,\n FloatingPortal,\n} from '@floating-ui/react';\n\ninterface PopoverProps {\n renderContent: (close: () => void) => React.ReactNode;\n children: React.ReactElement;\n placement?: 'bottom-start' | 'bottom-end' | 'top';\n}\n\nexport const Popover: React.FC<PopoverProps> = ({\n renderContent,\n children,\n placement = 'bottom-start',\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n\n const { refs, floatingStyles, context } = useFloating({\n open: isOpen,\n onOpenChange: setIsOpen,\n placement,\n middleware: [useOffset(8), useFlip(), useShift({ padding: 8 })],\n });\n\n const click = useClick(context);\n const dismiss = useDismiss(context);\n const role = useRole(context, { role: 'dialog' });\n\n const { getReferenceProps, getFloatingProps } = useInteractions([\n click,\n dismiss,\n role,\n ]);\n\n const trigger = React.cloneElement(children, {\n ref: refs.setReference,\n ...getReferenceProps(),\n });\n\n return (\n <>\n {trigger}\n {isOpen && (\n <FloatingPortal>\n <div\n ref={refs.setFloating}\n style={floatingStyles}\n {...getFloatingProps()}\n className=\"z-50 w-72 p-4 bg-white border border-slate-200 rounded-xl shadow-xl\"\n >\n {renderContent(() => setIsOpen(false))}\n </div>\n </FloatingPortal>\n )}\n </>\n );\n};\n\n\n— \n\n## Handling Edge Cases and Accessibility Best Practices\n\n### 1. The Escape Key and Focus Return\n\nWhen a popover opens, focus should ideally move inside the popover (or remain manageable). When closed via the Escape key (useDismiss handles this automatically), focus must return to the trigger element that opened it. Floating UI manages this state synchronization under the hood, preventing keyboard users from getting lost in the DOM tree.\n\n### 2. Screen Reader Verification\n\nAlways ensure your components pass automated auditing tools like Axe-core. For tooltips, inspect the generated HTML to verify that aria-describedby points directly to the unique id generated for the tooltip container:\n\nhtml\n<button aria-describedby=\":r1:\" data-testid=\"trigger\">Delete Item</button>\n\n<!-- Inside FloatingPortal -->\n<div role=\"tooltip\" id=\":r1:\">This action cannot be undone.</div>\n\n\n### 3. Handling Touch Devices Gracefully\n\nHover triggers do not exist on mobile devices. Floating UI intelligently normalizes pointer events, but for complex hover tooltips, consider implementing a fallback or pairing them with a long-press interaction, or strictly reserving tooltips for desktop viewports while using popovers for mobile interactions.\n\n—\n\n## Conclusion\n\nBy leveraging Floating UI alongside robust WAI-ARIA patterns, we’ve built a bulletproof tooltip and popover system that:\n\n* Automatically positions itself without clipping off-screen.\n* Handles hover, focus, and touch inputs seamlessly.\n* Provides graceful mouse transitions via safePolygon().\n* Respects keyboard navigation and dismisses cleanly on Escape.\n\nYou can now drop these primitives into your design system, confident that your interactive overlays are accessible to every user.” }

More posts