@ncjamieson

Favourites: Reach UI Tooltip

May 21, 2020 • 6 minute read

Neon sign
Photo by Yana Nikulina on Unsplash

A while ago, I decided I ought to start writing blog articles about some of my favourite components, packages and tools. I’d like to share not just what these things do and what prompted me to choose them, but also what I’ve learned from them, as — with components, in particular — there’s plenty that can be learned by delving into the source code.

This is the first of those articles. It’s a look at the Reach UI Tooltip: why I like it and what I learned from it.

Problems with tooltips

Whenever I’ve had to deal with them, tooltip and popup components have been a source of frustration. Too many of the components that I’ve used have suffered from at least one of the following problems:

  • styling that’s at odds with the application’s styling;
  • unwanted host elements in the DOM; and/or
  • buggy state management that sometimes results in multiple tooltips being visible.

So when Brian Vaughn added the Reach UI Tooltip to the React DevTools, I took a peek at it. I liked what I saw, I learned a few things from it and I started using it in the RxJS DevTools that I’ve been working on.

Let’s take look at how the tooltip avoids or solves the above problems and maybe learn a few things along the way.

Styling

Out of the box, the Reach UI components have minimal styling — just enough for the components to make visual sense. The default tooltip looks like this:

Default tooltip
Default tooltip

The tooltip’s default styles are declared in a styles.css file within the package and there are number of ways you can override them — although, you might choose to ignore them completely. If you do ignore them, targeting the tooltip popup’s element with a CSS selector is easy, as the element has a data-reach-tooltip attribute applied to it.

The most straightforward way to override the default styles is by specifying a styles prop for the Tooltip element:

<Tooltip
  label="Custom tooltip style"
  style={{
    backgroundColor: "#333",
    border: "none",
    borderRadius: "3px",
    color: "#fff",
  }}
>
  <button>❤️</button>
</Tooltip>

The style prop is passed through to the tooltip popup and the resulting tooltip looks like this:

Custom tooltip
Custom tooltip

That’s useful, but the really nice bit is that the tooltip has multiple levels of abstraction. In addition to the Tooltip component — that we’ve seen above — there are two lower-level abstractions:

  • a useTooltip hook; and
  • a TooltipPopup component.

You can use these lower-level abstractions to compose tooltips that include additional elements — like a triangle:

function Tooltip({ children, label, "aria-label": ariaLabel }) {
  const [trigger, tooltip] = useTooltip();
  const { isVisible, triggerRect } = tooltip;
  return (
    <Fragment>
      {cloneElement(children, trigger)}
      {isVisible && (
        <Portal>
          <div
            style={{
              borderBottom: "10px solid #333",
              borderLeft: "10px solid transparent",
              borderRight: "10px solid transparent",
              height: 0,
              left: triggerRect
                ? triggerRect.left - 10 + triggerRect.width / 2
                : undefined,
              position: "absolute",
              top: triggerRect
                ? triggerRect.bottom + window.scrollY
                : undefined,
              width: 0,
            }}
          />
        </Portal>
      )}
      <TooltipPopup
        {...tooltip}
        aria-label={ariaLabel}
        label={label}
        position={centered}
        style={{
          backgroundColor: "#333",
          border: "none",
          borderRadius: "3px",
          color: "#fff",
          padding: "0.5em 1em",
        }}
      />
    </Fragment>
  );
}

Which looks like this:

Tooltip with triangle
Tooltip with triangle

At first glance, it seems like there’s a lot of code involved with this, but almost all of it is styling. The useTooltip hook and the TooltipPopup component abstract the tedious and tricky stuff. Nice!

Host elements

It’s annoying if a tooltip component wraps its children with additional host elements, as that messes with inline elements. For example, it ought to be possible to add a tooltip to a span, like this:

<span>this next phrase</span>
<Tooltip label="This is the tooltip">
  <span>has a tooltip</span>
</Tooltip>

But if the Tooltip component puts its children into a div host element, it will break the layout.

The Reach UI Tooltip does not do this. It doesn’t wrap its children with anything. When the Reach UI Tooltip is used in the above JSX, the DOM will look like this:

<span>this next phrase</span>
<span data-reach-tooltip-trigger>has a tooltip</span>

It manages this using React’s cloneElement function. You might have noticed that it’s called in the triangle tooltip that we looked at earlier:

<Fragment>
  {cloneElement(children, trigger)}

Here, cloneElement creates a new element — that’s a clone of children — with props that are a shallow merge of the props in the original element with the props specified in trigger.

So the tooltip doesn’t mess with the DOM elements around the child that it wraps. Instead, it merges additional props — in particular, the mouse, keyboard and focus callbacks — into a clone of the child.

That means you can use a tooltip anywhere. You can put one around a span or an inline button and you can even put one inside an SVG.

State management

Out of the three problems that I listed earlier, buggy state management is the most common. The reason for this is that the logic involved in managing a tooltip’s state transitions is complicated: whether or not the tooltip is shown or dismissed depends upon the focus, the keyboard and the mouse.

The Reach UI Tooltip’s state management is solid. It uses state machine: a state chart and a transition function that’s called from the keyboard and mouse event handlers. A single state machine is used to manage the tooltip, so there’s no chance of multiple tooltips being shown simultaneously. It makes the complexity manageable and understandable. It just works.


Reach UI has a whole bunch of other components and, like the tooltip, they’re all written to be accessible and composable and have unopinionated styling. If the tooltip appeals to you, you should definitely check them out.

Additional resources

Michael Chan interviews Chance Strickland — one of Reach UI’s contributors — in this podcast: Reach UI and Building Composable Open Source.

David Khourshid looks at the advantages of styling with data attributes — they’re used extensively in the Reach UI compoments — in his talk at CSSConf BP 2019: Crafting Stateful Styles with State Machines.


Nicholas Jamieson’s personal blog.
Mostly articles about RxJS, TypeScript and React.
MastodonGitHubSponsor

© 2022 Nicholas Jamieson All Rights ReservedRSS