Accessible tooltips revisited

Zenobia Gawlikowska
EcoVadis Engineering
5 min readNov 27, 2023

Content displayed in tooltips must be accessible. However, the story doesn’t end there.

Photo by Tekton on Unsplash

The basics

Tooltips are used to display additional content and to clarify the purpose of a given interface element. As a design decision, this content might not be displayed in the main document flow, as it could make the it look too „chatty” and overcrowded. Perhaps, not all users need the information it provides. It must, however, be available on request. A clear affordance¹ must be present and all input modalities (keyboard, mouse, etc…) must enable access to it.

As the Web Accessibility Initiative Authoring Practices Guide puts it:

„A tooltip is a popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it. It typically appears after a small delay and disappears when Escape is pressed or on mouse out.”²

Leading UI libraries already provide this functionality out-of-the-box. I am thinking of Material-UI³ or React-Spectrum⁴. However, additional modifications might be necessary in order to make tooltips accessible and usable within accessible forms.

Tooltips within forms

Let’s consider this typical example: a text field with an icon acting as a UI affordance to trigger an explanatory tooltip:

Tooltip affordance
Tooltip activated

The basic implementation seems easy enough, following Material-UI’s documentation:

<Tooltip title="Alternative Organization Names...">
<InfoIcon />
</Tooltip>

The <InfoIcon> elements acts as trigger and the tooltip content is declared on the <Tooltip> wrapper. Easy.

Interaction is equally straightforward using the mouse. Hovering the icon with the pointer triggers the tooltip and hovering off makes it disappear. Because we are using a standard library, the keyboard focus behavior is already handled for us: keyboard focus triggers the tooltip and the escape key dismisses it. The text contained in the title attribute can be read by the screenreader on focus. We seem to be covered in all respects. Except one thing…

Contextual display of information

The additional information available in the tooltip content is only relevant when the associated field is in focus. This is not what is happening in our example. Instead, we need to leave the focused input field to focus on the tooltip icon, and only then will we be able to access the information contained therein. This problem is the most acute for screenreader users, as they will not have the visual reference ready to provide context. For this group of users, it might not be completely obvious what the tooltip content is referring to.

Typically, aria-labelledby or aria-describedby attributes would be used to solve this problem. As described on MDN Web Docs⁵:

„The aria-labelledby and aria-describedby attributes both reference other elements to calculate text alternatives. aria-labelledby should reference brief text that provides the element with an accessible name. aria-describedby is used to reference longer content that provides a description.”

In the world of Material UI input components, the aria-labelledby would correspond to the label attribute⁶ and the aria-describedby would correspond to the helperTextattribute.

<TextField
id="some-id"
label="Helper text"
helperText="Some important text"
/>

Let’s say our designers have decided to display the „helper text” solely in the tooltip, in order to avoid excessive use of screen real-estate. So, what now? We have to find a way to connect the <TextField> component to the description provided by the tooltip, so that focusing on the input text field triggers the tooltip content in a way that is both visually available and accessible to screenreaders.

An example of how this should look like is provided by accessibility-developer-guide⁷.

A React implemantation based on Material-UI

Looking from a consuming developer’s perspective, I would like the API to look like that:

<Tooltip placement=”top”>
<TooltipFocusContainer>
<TextField id="some-id" />
</TooltipFocusContainer>
</Tooltip>

My <TooltipFocusContainer> should not receive focus, but instead make anything that’s contained within it a trigger for the tooltip. What’s more, a text field contained within the container should reference the tooltip content with aria-describedby. So, let’s go about implementing those requirements using a bit of React context.

First, let’s create a container component, which passes on the focus trigger ref and accepts an icon component that will act as trigger:

import React, { createContext, useContext } from 'react';
import { InfoOutlined } from '@mui/icons-material';
import { JSXElementConstructor } from 'react';
import { SvgIconProps } from '@mui/material/SvgIcon';

export type TooltipFocusContainerProps = {
children: React.ReactNode;
Icon?: JSXElementConstructor<SvgIconProps>;
tooltipId?: string;
};

const DescribedByContext = createContext<string | undefined>('');

export const TooltipFocusContainer = React.forwardRef<HTMLDivElement, TooltipFocusContainerProps>(
({ children, Icon = InfoOutlined, tooltipId, ...props }: TooltipFocusContainerProps, ref) => (
<DescribedByContext.Provider value={tooltipId}>
<div {...props} tabIndex={-1}>
{children}
<div ref={ref}>{React.createElement(Icon)}</div>
</div>
</DescribedByContext.Provider>
),
);

Note: it’s necessary to pass the ref to the element which will trigger focus and remove the focusability by way of tabIndex={-1}. The <Tooltip> Material-UI component would otherwise force tabIndex={1}on its child element. We don’t want the container to be focusable. Only what’s inside — a form input element — should be focusable.

Now, we will need a small context hook, in order to get our aria-describedby id inside our custom implementation of a <TextField>component:

export const useDescribedBy = (id?: string) => {
const tooltipId = useContext(DescribedByContext);
return `${id ? `${id}-helper-text ` : ''}${tooltipId ? `${tooltipId}-tooltip-content` : ''}`;
};

Note: We need to pass an id prop, in order to identify the tooltip. It can also be retrieved from the context we have created above, if we passed it as tooltipId to our <TooltipFocusContainer>. In case a helper text has been used, we need to preserve a reference to it as well.

import React from 'react';
import MuiTextField, { MuiTextFieldProps } from '@mui/material/TextField';

import { useDescribedBy } from './Tooltip';

export const MyTextField = React.forwardRef<HTMLDivElement, MuiTextFieldProps>(
(
{
helperText,
id,
}: MuiTextFieldProps,
ref,
) => (
<MuiTextField
id={id}
ref={ref}
helperText={helperText}
inputProps={{
'aria-describedby': useDescribedBy(id),
}}
/>
),
);

And there we have it, a <TextField> component described by a tooltip⁸.

References

[1] https://www.uxpin.com/studio/blog/affordances-user-interaction/

[2] https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/

[3] https://mui.com/material-ui/react-tooltip/

[4] https://react-spectrum.adobe.com/react-aria/Tooltip.html

[5] https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby

[6] although the HTML semantics of the <label>element are sufficient to indicate it, using the for attribute

[7] https://www.accessibility-developer-guide.com/examples/widgets/tooltips/_examples/automatically-displayed-tooltip/

[8] Some browser/screenreader combinations might not properly announce elements appearing in the DOM and additional aria-live attributes might need to be added.

--

--