Skip to main content

Tooltip aria-describedby Was on the Wrong Element

Speech bubbles in a conversation
Apr 7, 20261 min readReact, ARIA, Design Systems

The bug

The Tooltip component wrapped its children in a div[role="presentation"] that handled the mouse and focus events, and the aria-describedby attribute lived on that wrapper, pointing at the tooltip's id. In the DOM inspector everything looked correct.

Screen readers never announced the tooltip content, though. A VoiceOver user focusing a button with a tooltip heard "Save, button" and nothing else, because aria-describedby sat on the role="presentation" wrapper rather than the <button> inside it. Assistive technology skips role="presentation" elements when it builds the accessibility tree, so the association went nowhere.

The fix

The tooltip needs to inject aria-describedby onto whatever element the consumer passes as children, which means cloning the child element:

const child = isValidElement(children)
  ? cloneElement(children as ReactElement<Record<string, unknown>>, {
      'aria-describedby': tooltipId,
    })
  : children;
 
return (
  <div className='relative inline-block'>
    <div
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
      onFocus={showTooltip}
      onBlur={hideTooltip}
      role='presentation'
    >
      {child}
    </div>
    <div id={tooltipId} role='tooltip' aria-hidden={!isVisible}>
      {content}
    </div>
  </div>
);

The wrapper div still handles the events, but aria-describedby now lands on the actual interactive element: the <button>, <a>, or whatever the child happens to be. The tooltip div also picks up aria-hidden={!isVisible} so screen readers don't read its content as regular page text while it's sitting at opacity 0.

This meant changing the children type from ReactNode to ReactElement, since cloneElement needs an element, not a string or a fragment. That's a reasonable constraint, since a tooltip without an interactive trigger element isn't much use anyway.

Testing the right thing

The original test asserted that the wrapper had aria-describedby, and it passed because the wrapper did. The test was right about the implementation and wrong about the spec.

The updated test checks the child element instead:

it('applies aria-describedby to the child element, not the wrapper', () => {
  const { container } = render(
    <Tooltip content='Tooltip text'>
      <button>Hover me</button>
    </Tooltip>
  );
  const button = screen.getByRole('button', { name: 'Hover me' });
  const tooltip = container.querySelector('[role="tooltip"]');
  const wrapper = container.querySelector('[role="presentation"]');
 
  expect(button).toHaveAttribute(
    'aria-describedby',
    tooltip?.getAttribute('id')
  );
  expect(wrapper).not.toHaveAttribute('aria-describedby');
});

The negative assertion is the part that matters, because it documents that the wrapper should not carry the attribute and stops a future change from re-adding it to the wrong element.

The takeaway

Testing for the presence of an ARIA attribute is only half the job; the other half is testing which element it sits on. The wrapper had the right attribute, but the child element was the one that needed it. Screen readers don't care about your wrapper divs.