DEV Community

Mica
Mica

Posted on

How to create an accessible tooltip using React

Happy 2024, everyone!!! I hope you had a great starting. On this side, we're going to celebrate the beginning of the year with a new entry for this wonderful series of accessible components. This time, I'm going to share with you how to create an accessible dynamic tooltip and how to use the clipboard API in JavaScript. Let's get started because things are getting exciting!

Project repository: https://github.com/micaavigliano/accessible-tooltip
Project link: https://accessible-tooltip.vercel.app/

First and foremost, let's discuss a bit about what we want to achieve to make our clipboard copy button accessible. We need:

1) The button to receive focus and have an accessible name.
2) The copy button to have a tooltip that appears when the user hovers over it or when it receives focus.
3) The tooltip content to be dynamic, and that this content is announced by the screen reader each time it changes.
4) The content we want to copy should indeed be copied to the clipboard.
5) The copy component should be reusable and can be either static text or an input.
6) The tooltip should close when the user presses the Esc key.

Our specifications are clear and straightforward. Let's start coding!

Tooltip.tsx

interface TooltipProps {
  text: string;
  children: ReactNode;
  direction: "top" | "bottom" | "left" | "right";
  id: string;
}
Enter fullscreen mode Exit fullscreen mode
  • Text: It will be the text that our tooltip will contain.
  • Children: The element that will contain the tooltip.
  • Direction: The direction in which we want our tooltip to appear.
  • ID: An ID to relate it to the aria-describedby attribute that the children will have.

Let's start breaking down the component:

  1. We'll need to set a state to manage the visibility of the tooltip:
const [showTooltip, setShowTooltip] = useState<boolean>(false);
Enter fullscreen mode Exit fullscreen mode
  1. Next, let's create two functions to handle this state: tooltipOn and tooltipOff:
const tooltipOn = () => {
  setShowTooltip(true);
};

const tooltipOff = () => {
  setShowTooltip(false);
};
Enter fullscreen mode Exit fullscreen mode

These functions will be passed to our container element to show the tooltip when hovered using the onMouseEnter function, hide the tooltip when the mouse leaves that element with onMouseLeave, display the tooltip again when the element receives focus with onFocus, and hide the tooltip when focus leaves that element with onBlur. Additionally, to ensure our tooltip is 100% functional and accessible, we will create a function to make it disappear when the user presses the Escape key.

const closeTooltip = (ev: KeyboardEvent) => {
    if (ev.key === "Escape") {
      setShowTooltip(false);
    }
};
Enter fullscreen mode Exit fullscreen mode

and handle the event in a useEffect

 useEffect(() => {
    document.addEventListener("keydown", closeTooltip);

    return () => {
      document.removeEventListener("keydown", closeTooltip);
    };
  }, []);
Enter fullscreen mode Exit fullscreen mode

Let's see how our component is going to look:

<div
   className="relative inline-block justify-center text-center"
   onMouseEnter={tooltipOn}
   onMouseLeave={tooltipOff}
   onFocus={tooltipOn}
   onBlur={tooltipOff}
>
  {showTooltip && (
    <div
      className={`bg-black text-white text-center rounded p-3 absolute z-10 transition-opacity duration-300 ease-in-out w-fit outline outline-offset-0 ${
      direction === "top" ? "bottom-[calc(100%+1px)] left-10 transform translate-x-[-60%] mb-2"
              : ""
          }
          ${
            direction === "bottom"
              ? "top-[calc(100%+1px)] left-10 transform translate-x-[-60%] mt-2"
              : ""
          }
          ${
            direction === "left"
              ? "-left-100 top-1/2 transform -translate-y-1/2 mr-2"
              : ""
          }
          ${
            direction === "right"
              ? "-right-100 top-1/2 transform -translate-y-1/2 ml-2"
              : ""
          }`}
          data-placement={direction}
          role="tooltip"
          id={id}
          style={getTooltipStyle()}
        >
          {text}
        </div>
      )}
      {children}
    </div>
Enter fullscreen mode Exit fullscreen mode

To make our Tooltip accessible, we'll need to pass it a series of attributes:

  1. role="tooltip": While semantically it may not represent a significant change, it does so in reference terms by helping screen readers identify and associate the tooltip with its related element. What do I mean by this? Well, any element that contains role="tooltip" must be related to another element that contains aria-labelledby (in this case, the children should have it). This is because the tooltip provides additional information about the element.

  2. id: The ID of the element to be related through aria-describedby.

  3. data-placement: It will receive the direction property, which will be the direction of our tooltip.

CopyToClipboard.tsx

Now, let's move on to one of the most enjoyable components I've had the pleasure of creating. I don't know why, I just grew very fond of it.

Let's start with its properties; in this case, we have one optional and one mandatory:

interface ICopyToClipboard {
  text?: string;
  type: "text" | "input";
}
Enter fullscreen mode Exit fullscreen mode
  1. Text: The text that our component will receive in the case of being of type text.
  2. Type: It can only take two values, either text or input. Text will be a static value, while input will be a dynamic one.

In this case, our copy button will be wrapped in the Tooltip component. The button will have an onClick attribute that will receive the handleCopyText function, which we'll discuss a little later, and the aria-labelledby attribute to relate it to our tooltip.

<Tooltip text={copyText} direction={"bottom"} id={"copyid"}>
 <button onClick={handleCopyText} aria-labelledby="copyid">
  <ContentCopy />
 </button>
</Tooltip>
Enter fullscreen mode Exit fullscreen mode

Let's move on to the handleCopyText function.

  1. We need to create a state to manage the text: const [copyText, setCopyText] = useState<string>("Copy to clipboard");
  2. To continue with the handleCopyText function, we have to understand what the clipboard API is. The clipboard API allows us to interact with the clipboard of an operating system. It contains methods and functions to access and manipulate the information stored on the clipboard (copy, paste, and cut). In this case, we will make use of the writeText() method. This method takes a required text parameter and returns a promise. If the operation is successful, the then() method will be executed, changing the value of our copyText state. After 5 seconds, the text will revert to "Copy to clipboard". In this case, the writeText method parameter will receive either text or inputValue, depending on which is not null. This is because if we pass type="input" to our component, we won't be providing a default static text.
const handleCopyText = () => {
    navigator.clipboard.writeText(text || inputValue).then(() => {
      setCopyText("Copiado");
      setTimeout(() => {
        setCopyText("Copiar al portapapeles");
      }, 5000);
    });
  };
Enter fullscreen mode Exit fullscreen mode

And... there you have it! As simple as that, we now have our accessible text copy button with an accessible tooltip as well. Finally, I'd like to show you how the screen reader announces the content of our tooltip:

  1. Initial state:

Image of the screen reader voice-over announcing: "Copy to clipboard, button"

  1. Clicked the copy button

Image of the screen reader voice-over announcing: "Copied"

  1. Back to initial state after 5 seconds

Image of the screen reader voice-over announcing: "Copy to clipboard"

Now, I hope you enjoyed this component as much as I did, and please share if you've ever come across a tooltip and thought it could be made accessible. Finally, here's the list of resources I used to gather information:

Top comments (2)

Collapse
 
abdermaiza profile image
Abdermaiza

Hi,

Your tooltip example isn't accessible enough because should remove with the Escap key and stay on hover with the mouse...

:)

Collapse
 
micaavigliano profile image
Mica

That case it is covered