How To Make Tooltip with React and PopperJS
Published: 2023-01-23 07:10:03

✨ [**Watch on YouTube**](https://youtu.be/MHIfZjjD5a8)  |  🐙 [GitHub](https://github.com/RodionChachura/react-toolkit)  |  🎮 [Demo](https://reactkit.radzion.com/button)

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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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};
`
```