DEV Community

Cover image for A Step-by-Step Guide to Building a Custom Tooltip in React using useRef and Hooks
Andrew McCallum
Andrew McCallum

Posted on • Edited on

A Step-by-Step Guide to Building a Custom Tooltip in React using useRef and Hooks

In this blog post, I will guide you through the process of creating a reusable and extensible tooltip component using the useRef hook and custom hooks. By following along, you will not only gain a deeper understanding of how the useRef hook functions, but also learn how to implement your own hooks. It's an excellent opportunity to enhance your knowledge and skills in React development.

If you want to follow along, you may want to use https://codesandbox.io and I'd start by creating the following file structure. Otherwise, keep reading and find a working example at the end.

├── src
│   ├── App.tsx
│   ├── Tooltip.tsx
│   ├── Tooltip.hooks.tsx
│   ├── Tooltip.css
Enter fullscreen mode Exit fullscreen mode

Firstly, we are going to create our Tooltip component. This will be a simple component that just renders its children. It will also receive an elementRef as a prop that will come in handy later.

// Tooltip.tsx
import React, { FC, RefObject } from "react";

type TooltipProps = {
  elementRef: RefObject<HTMLElement>;
  children: React.ReactNode;
};

const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
  return <div>{children}</div>;
};

export default Tooltip;
Enter fullscreen mode Exit fullscreen mode

In our App.tsx, we are now going to render a button to which we attach a ref. We also render our Tooltip component and pass our button ref into it.

// App.tsx
import React, { useRef } from "react";
import Tooltip from "./Tooltip";

export default function App() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  return (
    <>
      <Tooltip elementRef={buttonRef}>
        <span>I'm a tooltip</span>
      </Tooltip>
      <div className="App">
        <button className="button" ref={buttonRef}>
          Hello world
        </button>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we are going to create our custom hook. This hook will contain the logic for making the tooltip visible and positioning it on the screen.

// Tooltip.hooks.tsx
import { useState } from "react";

type Position = {
  top?: number;
  left?: number;
  width?: number;
};

export function useTooltip() {
  const [isVisible, setIsVisible] = useState<boolean>(false);
  const [position, setPosition] = useState<Position>({});

  return {
    position: {
      top: position.top ?? 0,
      left: position.left ?? 0,
      width: position.width ?? 0,
    },
    isVisible,
  };
}
Enter fullscreen mode Exit fullscreen mode

Let's now use our useTooltip hook in our Tooltip component. If our tooltip is not visible, we'll return nothing, and we will make sure we pass the top and left positions into our div's inline styles. We need to use inline styles here because the values are dynamic, so we don't want to hardcode them in our CSS file.

// Tooltip.tsx
import React, { FC, RefObject } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css"; // importing our styles here

type TooltipProps = {
  elementRef: RefObject<HTMLElement>;
  children: React.ReactNode;
};

const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
  const { position, isVisible } = useTooltip();

  if (!isVisible) {
    return null;
  }

  return (
    <div
      className="tooltip-container" // adding className here for later use
      style={{
        top: position.top,
        left: position.left,
      }}
    >
      {children}
    </div>
  );
};

export default Tooltip;
Enter fullscreen mode Exit fullscreen mode

We want the useTooltip hook to have some context about the element we are hovering over, so we are going to pass our button ref from the Tooltip component into the hook. While we're here, we are also going to add a couple of functions that will be used when the user hovers over our button. These are onMouseEnter for when the cursor hovers over the element, and onMouseLeave for when the cursor exits the hover. When these events occur, we will set our tooltip to visible.

// Tooltip.hooks.tsx
import { useState, RefObject, useCallback, useEffect } from "react";

type Position = {
  top?: number;
  left?: number;
  width?: number;
};

type UseTooltipProps = {
  ref: RefObject<HTMLElement>;
};

export function useTooltip({ ref }: UseTooltipProps) {
  const [isVisible, setIsVisible] = useState<boolean>(false);
  const [position, setPosition] = useState<Position>({});

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    if (isVisible) {
      const { left, width, bottom } = ref.current.getBoundingClientRect();
      setPosition({
        top: bottom,
        left: left,
        width,
      });
    }

    if (!isVisible) {
      setPosition({});
    }
  }, [isVisible, ref]);

  const onMouseEnter = useCallback(() => {
    setIsVisible(true);
  }, []);

  const onMouseLeave = useCallback(() => {
    setIsVisible(false);
  }, []);

  return {
    position: {
      top: position.top ?? 0,
      left: position.left ?? 0,
      width: position.width ?? 0,
    },
    isVisible,
    onMouseEnter,
    onMouseLeave,
  };
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we have defined two new functions: onMouseEnter and onMouseLeave, which we define using useCallback. This allows us to cache the function definition between re-renders and ensures that whenever we set our position or isVisible values, the functions don't get redefined, avoiding unnecessary renders that can result in a flashing component.

In our onMouseEnter function, we set isVisible to true so that the Tooltip is actually rendered, and in our onMouseLeave function, we set it to false.

You may also notice that we are setting the position of our tooltip in a useEffect that gets called whenever isVisible changes. This will make sense later, but essentially, we only want to set our tooltip's position once it has rendered because we are going to need to calculate its dimensions to position it correctly.

For now, we are aligning the left side of our button with the left side of our tooltip and the top of our tooltip with the bottom of our button. We will improve this shortly.

Now we can update our Tooltip component to pass our button ref and attach the onMouseEnter / onMouseLeave events to it.

// Tooltip.tsx
import React, { FC, RefObject, useEffect } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css";

type TooltipProps = {
  elementRef: RefObject<HTMLElement>;
  children: React.ReactNode;
};

const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
  const tooltipRef = useRef<HTMLDivElement>(null);

  const {
    position,
    isVisible,
    onMouseEnter,
    onMouseLeave,
  } = useTooltip({
    ref: elementRef,
    tooltipRef,
  });

  useEffect(() => {
    const element = elementRef?.current;

    if (element) {
      element.addEventListener("mouseenter", onMouseEnter);
      element.addEventListener("mouseleave", onMouseLeave);
    }

    // cleans up event listeners by removing them when the component is unmounted
    return () => {
      if (element) {
        element.removeEventListener("mouseenter", onMouseEnter);
        element.removeEventListener("mouseleave", onMouseLeave);
      }
    };
  }, [elementRef, onMouseEnter, onMouseLeave]);

  if (!isVisible) {
    return null;
  }

  return (
    <div
      ref={tooltipRef}
      className="tooltip-container" // adding className here for later use
      style={{
        top: position.top,
        left: position.left,
      }}
    >
      {children}
    </div>
  );
};

export default Tooltip;
Enter fullscreen mode Exit fullscreen mode

If we add some CSS to our Tooltip, we should be able to start seeing this come together.

/* Tooltip.css */

.tooltip-container {
  position: absolute;
  font-size: 14px;
  line-height: 20px;
  background-color: #2c3a43;
  color: white;
  padding: 16px;
  margin: 0;
}

.tooltip-container::before {
  content: "";
  position: absolute;
  top: -12px;
  left: 50%;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 16px solid transparent;
  border-right: 16px solid transparent;
  border-bottom: 18px solid #2c3a43;
}
Enter fullscreen mode Exit fullscreen mode

tooltip screenshot

You'll notice the tooltip isn't positioned where we want it, so let's go ahead and fix its position.

We're going to update the onMouseEnter function to do some simple math. We want our tooltip to be pointing to the middle of our button, so we need to align the middle of our tooltip div with the middle of the button element. To do this, our useTooltip hook needs to be aware of the Tooltip's div element and be able to get some dimensions from it.

We get the dimensions of the tooltip within a useEffect because that ensures the isVisible is set to true. That way the element has actually rendered and has a width set. Otherwise, if the element has not rendered, it would have a width of zero and we wouldn't be able to calculate the middle point.

Next we will align our tooltip below our button by providing a verticalOffset

// Tooltip.hooks.tsx
import React, { RefObject, useCallback, useEffect, useState } from "react";

type UseTooltipProps = {
  ref: RefObject<HTMLElement>;
  tooltipRef: RefObject<HTMLDivElement>;
};

type Position = {
  top?: number;
  left?: number;
  width?: number;
};

export function useTooltip({ ref, tooltipRef }: UseTooltipProps) {
  const [isVisible, setIsVisible] = useState<boolean>(false);
  const [position, setPosition] = useState<Position>({});

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    if (isVisible) {

      const { left, width, bottom } = ref.current.getBoundingClientRect();

      const tooltipWidth = tooltipRef?.current?.getBoundingClientRect().width || 0;

      const middle = left + width / 2 - tooltipWidth / 2;

      const verticalOffset = 12 // If you change the size of the arrow in the css class tooltip-container::before then you will need to change this value

      setPosition({
        top: bottom + verticalOffset,
        left: middle,
        width,
      });
    }

    if (!isVisible) {
      setPosition({});
    }
  }, [isVisible, ref, tooltipRef]);

  const onMouseEnter = useCallback(() => {
    setIsVisible(true);
  }, []);

  const onMouseLeave = useCallback(() => {
    setIsVisible(false);
  }, []);

  return {
    position: {
      top: position.top ?? 0,
      left: position.left ?? 0,
      width: position.width ?? 0,
    },
    isVisible,
    onMouseEnter,
    onMouseLeave,
  };
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can update our Tooltip component to create a ref for our Tooltip div and pass it to our hook so that the aforementioned calculations can be done.

// Tooltip.tsx
import React, { FC, RefObject, useEffect, useRef } from "react";
import { useTooltip } from "./Tooltip.hooks";
import "./Tooltip.css";

type TooltipProps = {
  elementRef: RefObject<HTMLElement>;
  children: React.ReactNode;
};

const Tooltip: FC<TooltipProps> = ({ children, elementRef }) => {
  const tooltipRef = useRef<HTMLDivElement>(null);

  const {
    position,
    isVisible,
    onMouseEnter,
    onMouseLeave,
  } = useTooltip({
    ref: elementRef,
    tooltipRef,
  });

  useEffect(() => {
    const element = elementRef?.current;

    if (element) {
      element.addEventListener("mouseenter", onMouseEnter);
      element.addEventListener("mouseleave", onMouseLeave);
    }

    // cleans up event listeners by removing them when the component is unmounted
    return () => {
      if (element) {
        element.removeEventListener("mouseenter", onMouseEnter);
        element.removeEventListener("mouseleave", onMouseLeave);
      }
    };
  }, [elementRef, onMouseEnter, onMouseLeave]);

  if (!isVisible) {
    return null;
  }

  return (
    <div
      ref={tooltipRef}
      className="tooltip-container" // adding className here for later use
      style={{
        top: position.top,
        left: position.left,
      }}
    >
      {children}
    </div>
  );
};

export default Tooltip;
Enter fullscreen mode Exit fullscreen mode

There we have it! Our tooltip should now display below our button when we hover over it.

Final tooltip screenshot

One thing that makes this component highly extensible is the method we use to pass the tooltip content as children. While many components only accept a text prop, we enable users to send a React node, granting them the ability to render any component with their preferred styles. However, a potential downside of this approach, especially in a design system context, is the need to maintain consistency with the intended designs. In such cases, it may be advisable to provide some level of prescription regarding how the tooltip content should be rendered.

In conclusion, we have learned how to create a versatile and reusable tooltip component using React, useRef, and custom hooks. By leveraging useRef, we were able to create a tooltip that can be attached to any element on the screen. The use of custom hooks allowed us to handle the tooltip's visibility and positioning logic in a clean and reusable manner. With the flexibility of passing React nodes as tooltip content, we can customise the appearance and behaviour of our tooltips to suit our specific needs.

Checkout the working version here

Top comments (0)