DEV Community

Cover image for How To Make Tooltip with React and PopperJS
Rodion Chachura
Rodion Chachura

Posted on • Originally published at radzion.com

How To Make Tooltip with React and PopperJS

Watch on YouTube | 🐙 GitHub | 🎮 Demo

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;
Enter fullscreen mode Exit fullscreen mode

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)

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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>
)
Enter fullscreen mode Exit fullscreen mode

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;
`
Enter fullscreen mode Exit fullscreen mode

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};
`
Enter fullscreen mode Exit fullscreen mode

Top comments (0)