How To Make Tooltip React Component with Arrow using Floating UI

How To Make Tooltip React Component with Arrow using Floating UI

June 29, 2023

3 min read

How To Make Tooltip React Component with Arrow using Floating UI
Watch on YouTube

Let's make a beautiful reusable tooltip component with React and Floating UI. It will automatically reposition itself when there is not enough space, have an arrow pointing to a referencing element, and look good in both dark and light modes.

Example of the tooltip component
Example of the tooltip component

Tooltip React Component

The Tooltip component receives a content prop with the text to display inside the tooltip, a renderOpener prop with a function that returns a React element that will open the tooltip when hovered, and a placement prop with the preferred placement of the tooltip relative to the opener.

import { ReactNode, useRef, useState } from "react"
import {
  ReferenceType,
  offset,
  shift,
  flip,
  useFloating,
  autoUpdate,
  useInteractions,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  arrow,
  FloatingArrow,
  useTransitionStyles,
  Placement,
} from "@floating-ui/react"
import styled from "styled-components"
import { getColor } from "./theme/getters"

export interface RenderOpenerProps extends Record<string, unknown> {
  ref: (node: ReferenceType | null) => void
}

interface TooltipProps {
  content?: ReactNode
  renderOpener: (props: RenderOpenerProps) => ReactNode
  placement?: Placement
}

const Container = styled.div`
  border-radius: 8px;
  background: ${getColor("contrast")};
  color: ${getColor("background")};
  padding: 12px;
  font-size: 14px;
  font-weight: 500;
  max-width: 320px;
`

const Arrow = styled(FloatingArrow)`
  fill: ${getColor("contrast")};
`

export const Tooltip = ({ content, renderOpener, placement }: TooltipProps) => {
  const [isOpen, setIsOpen] = useState(false)

  const {
    refs: { setReference, setFloating },
    floatingStyles,
    context,
  } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    placement,
    middleware: [
      offset(12),
      flip(),
      shift(),
      arrow({
        element: arrowRef,
      }),
    ],
    whileElementsMounted: autoUpdate,
  })

  const arrowRef = useRef(null)

  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,
  ])

  const { styles: transitionStyles } = useTransitionStyles(context, {
    initial: {
      opacity: 0,
      transform: "scale(0.8)",
    },
  })

  return (
    <>
      {renderOpener({ ref: setReference, ...getReferenceProps() })}
      {isOpen && content && (
        <div
          ref={setFloating}
          style={{ ...floatingStyles, zIndex: 1 }}
          {...getFloatingProps()}
        >
          <Container style={transitionStyles}>
            <Arrow tipRadius={2} height={8} ref={arrowRef} context={context} />
            {content}
          </Container>
        </div>
      )}
    </>
  )
}

We store the opened/closed state in the useState hook. All the magic for positining the floating element lies in the useFloating hook. We pass the isOpen state and the setIsOpen function to the hook, so it can update the state when the user hovers over the opener, we also propagate the placement prop to the hook, so it knows where to position the tooltip, and middleware for modifying the position of the tooltip. In this case offset creates a distance between the opener and tooltip, flip and shift make sure that the tooltip gets repositioned when there is not enough space, and arrow creates an arrow pointing to the opener.

To trigger appearance on hover we rely on the useHover hook, to also display the tooltip on focus we have the useFocus hook, and to hide the tooltip on escape key press - the useDismiss hook. To get properties for accessibility purposes we apply the useRole hook. To combine all those effects we use the useInteractions hook.

To apply animation for appearance there is the useTransitionStyles hook. It returns a styles object that we can apply to the tooltip container. To make tooltip stand out on both dark and light mode we reverse colors by making text color the same as the background color and vice versa.

Finally we render the opener and the tooltip itself. We pass all the props and ref to the renderOpener function, and when the isOpen is true we render the tooltip itself. We apply position styles to a regular div, while passing transitionStyles to the tooltip container.