How To Make Tooltip with React and PopperJS

January 11, 2023

3 min read

How To Make Tooltip with React and PopperJS
Watch on YouTube

Let's make a tooltip with React and PopperJS. To demonstrate it, we'll implement a UX pattern, showing a hint when hovering a disabled button.

isDisabled?: boolean | string;

The components has isDisabled property that could be either boolean or a string so we don't need to pass an extra property to include a hint for a disabled button.

export const RectButton = ({
  children,
  size = "m",
  isDisabled = false,
  isLoading = false,
  onClick,
  onMouseEnter,
  onMouseLeave,
  ...rest
}: Props) => {
  const [anchor, setAnchor] = useState<HTMLElement | null>(null)

  const [isTooltipOpen, { unset: hideTooltip, set: showTooltip }] =
    useBoolean(false)

  // ...
}

To show the tooltip, we'll need a ref of the button container and a boolean state to know if the user hovers the element. We'll show the tooltip only if isDisabled is a string. To update the isTooltipOpen we all add onMouseEnter and onMouseLeave event handlers.

const isTooltipEnabled = typeof isDisabled === "string"

return (
  <Container
    size={size}
    isDisabled={!!isDisabled}
    isLoading={isLoading}
    onClick={isDisabled || isLoading ? undefined : onClick}
    onMouseEnter={(event: MouseEvent<HTMLButtonElement>) => {
      onMouseEnter?.(event)
      isTooltipEnabled && showTooltip()
    }}
    onMouseLeave={(event: MouseEvent<HTMLButtonElement>) => {
      onMouseLeave?.(event)
      isTooltipEnabled && hideTooltip()
    }}
    ref={setAnchor}
    {...rest}
  >
    {isLoading ? (
      <div>
        <Spinner />
      </div>
    ) : (
      <>{children}</>
    )}
    {anchor && isTooltipOpen && (
      <Popover placement="bottom" anchor={anchor}>
        <TooltipContainer>{isDisabled}</TooltipContainer>
      </Popover>
    )}
  </Container>
)

If we have the anchor and the tooltip is open, we'll show the tooltip wrapped with the Popover component. We set reversed text and background colors to the tooltip, so it stands out on both dark and light themes. We add keyframes with up movement and opacity for a smoother appearance.

const tooltipAnimation = keyframes`
  from {
    transform: translateY(4px);
    opacity: 0.6;
  }
`

const TooltipContainer = styled.div`
  border-radius: 4px;
  padding: 4px 8px;
  background: ${({ theme }) =>
    theme.colors.text.getVariant({ a: () => 1 }).toCssValue()};
  color: ${({ theme }) => theme.colors.background.toCssValue()};
  font-size: 14px;

  animation: ${tooltipAnimation} 300ms ease-out;
`

Here we set up the Popover component with PopperJS, add a handler for clicking outside, force update on size change, and render everything inside of a body portal.

import { Placement } from "@popperjs/core"
import { ReactNode, useEffect, useState } from "react"
import { usePopper } from "react-popper"
import { useClickAway } from "react-use"
import styled from "styled-components"
import { BodyPortal } from "lib/ui/BodyPortal"
import { useElementSize } from "lib/ui/hooks/useElementSize"
import { ScreenCover } from "lib/ui/ScreenCover"
import { zIndex } from "lib/ui/zIndex"
import { useValueRef } from "lib/shared/hooks/useValueRef"

export type PopoverPlacement = Placement

interface PopoverProps {
  anchor: HTMLElement
  children: ReactNode
  placement?: PopoverPlacement
  distance?: number
  enableScreenCover?: boolean
  onClickOutside?: () => void
}

export const Popover = styled(
  ({
    anchor,
    children,
    onClickOutside,
    placement = "auto",
    distance = 4,
    enableScreenCover = false,
  }: PopoverProps) => {
    const [popperElement, setPopperElement] = useState<HTMLElement | null>(null)

    const { styles, attributes, update } = usePopper(anchor, popperElement, {
      placement,
      strategy: "fixed",

      modifiers: [
        {
          name: "offset",
          options: {
            offset: [0, distance],
          },
        },
        {
          name: "preventOverflow",
          options: {
            padding: 8,
          },
        },
      ],
    })

    const poperRef = useValueRef(popperElement)
    useClickAway(poperRef, (event) => {
      if (anchor.contains(event.target as Node)) return
      onClickOutside?.()
    })

    const size = useElementSize(popperElement)
    useEffect(() => {
      if (!update) return

      update()
    }, [size, update])

    const popoverNode = (
      <Container
        ref={setPopperElement}
        style={styles.popper}
        {...attributes.popper}
      >
        {children}
      </Container>
    )

    return (
      <BodyPortal>
        {enableScreenCover && <ScreenCover />}
        {popoverNode}
      </BodyPortal>
    )
  }
)``

const Container = styled.div`
  position: relative;
  z-index: ${zIndex.menu};
`