Design system

Tooltip

A short label that appears on hover or focus.

import { Tooltip } from "@/ui";

Tooltip wraps MUI's Tooltip in Petavue styling: a dark box with 12px white text that fades in over 180ms. Use it to name an icon-only control, to explain a control whose label cannot say everything, or to show text that has been cut short.

InfoTooltip, also exported from @/ui, is a different thing: a static label with an info icon that does not open or position itself. src/components/Tooltip.jsx is a third, hand-built tooltip that nothing imports. Use the default Tooltip export described here.

Usage

Wrap one element and give the text in title. Pass describeChild so the element keeps its own name and the tooltip is read as extra description.

<Tooltip title="Pull the latest numbers from HubSpot and Google Ads" describeChild>
  <Button variant="secondaryGhost" label="Refresh data" icon={ArrowsClockwise} />
</Tooltip>

The child must accept a ref and pass on the event props it receives. Button, native elements and anything built with forwardRef do. A plain function component that drops unknown props does not, and the tooltip will never open.

Icon-only buttons

Give the button an aria-label and pass describeChild. A child's own aria-label always takes priority over the tooltip, and with describeChild the tooltip text is never used as the name, so the tooltip can later say more than the label without renaming the button.

<Tooltip title="Delete page" describeChild>
  <Button variant="secondaryGhost" icon={Trash} aria-label="Delete page" />
</Tooltip>

When the tooltip is the name

Without describeChild, a text title is copied onto the child as its aria-label, unless the child passes an aria-label of its own. That is fine for an icon-only button with no label, where the tooltip text is the name.

<Tooltip title="Download as CSV">
  <Button variant="secondaryGhost" icon={DownloadSimple} />
</Tooltip>
Without describeChildWith describeChild
Text titleSet as the child's aria-label. An aria-label passed on the child wins.The child keeps its name. While open, the tooltip is linked with aria-describedby. While closed, the text is also set as the child's title attribute.
Element titleWhile open, the tooltip becomes the child's name through aria-labelledby.While open, linked with aria-describedby.

Any control with visible text needs describeChild. An aria-label overrides visible text, so without it "Refresh data" above would be announced as "Pull the latest numbers from HubSpot and Google Ads".

Placement

The default is bottom. Use top for most controls in the product so the tooltip does not cover what comes next. left and right suit narrow rails. Add arrow when the tooltip could be read as belonging to a neighbouring control. MUI flips the tooltip to the other side if there is no room.

<Tooltip title="Opens above" placement="top">…</Tooltip>
<Tooltip title="Opens below" placement="bottom">…</Tooltip>
<Tooltip title="Opens to the left" placement="left">…</Tooltip>
<Tooltip title="Opens to the right" placement="right">…</Tooltip>
<Tooltip title="With an arrow" placement="top" arrow>…</Tooltip>

Text that is cut short

With displayTooltipOnOverflow, the tooltip only opens when its child is actually truncated. Hover both names: only the long one opens. The child must be the element that has the ellipsis, because that is the one measured. A ResizeObserver re-checks when the width changes.

<Tooltip title={campaign.name} displayTooltipOnOverflow placement="top">
  <span className="block truncate max-w-[200px]">{campaign.name}</span>
</Tooltip>

Rich content

title can be any element, such as a small legend. Keep it to a few lines. Raise or lower the width cap with maxWidth. If people need to click inside it, use a popover instead.

<Tooltip title={<SyncStatusLegend />} arrow placement="top" maxWidth="260px" describeChild>
  <span tabIndex={0} aria-label="What the sync colours mean"><Info size={16} /></span>
</Tooltip>

On a disabled control

A disabled button does not fire the events the tooltip listens for, and MUI logs an error if you wrap one directly. Wrap it in a focusable <span> and put the tooltip on that. This is the right place to say why the action is not available.

<Tooltip title="Connect a domain before you publish" placement="top">
  <span className="inline-flex" tabIndex={0}>
    <Button variant="primary" label="Publish page" disabled />
  </span>
</Tooltip>

Look

PartStyle
Box#424766 (--color-neutral-700) with a 1px #EEF0F7 (--color-neutral-100) border, 6px corners, 4px by 12px padding.
Text12px Poppins, white.
WidthUp to maxWidth, 642px by default.
ArrowSame colour as the box.
Gap6px from the child, plus MUI's 4px margin.
MotionFades in and out over 180ms.

The colours are hard-coded in Tooltip.jsx rather than read from the tokens. They match the tokens named above.

Props

PropTypeDefaultNotes
titleReactNodeRequired for the tooltip to show. An empty value shows nothing.
childrenone elementMust accept a ref and pass on event props.
placement"top" | "bottom" | "left" | "right", plus -start and -end forms"bottom"
arrowbooleanfalse
describeChildbooleanfalseKeep the child's own name. See above.
displayTooltipOnOverflowbooleanfalseOnly open when the child is truncated.
maxWidthstring"642px"
tooltipActivebooleantruefalse renders the child with no tooltip at all.
disableHoverbooleanfalseHover does nothing. Focus and touch still open it.
openbooleanControls the open state from outside.
disablePortalbooleanfalseRender inside the parent instead of at the end of <body>. Rarely needed.
styles{ tooltip?, arrow? }{}Style overrides for the box and the arrow. Avoid; the look should match everywhere.
triggerResizePropsarray[]Accepted but not used for anything except the memo comparison.

Any other prop (enterDelay, leaveDelay, onOpen, onClose, followCursor and so on) is passed to MUI's Tooltip.

The component is wrapped in memo with a custom comparison that only looks at children, title, placement, open, tooltipActive, displayTooltipOnOverflow, disableHover, disablePortal, maxWidth, styles and triggerResizeProps. Changing any other prop on its own, such as arrow or describeChild, may not re-render the tooltip.

Accessibility

  • The tooltip opens on mouse hover after 100ms, on keyboard focus, and on a long press on touch screens. It closes when the pointer or focus leaves, and on Escape.
  • The tooltip itself has role="tooltip".
  • Do not put anything people need to read only in a tooltip. Touch users rarely see it. Put essential information on the page.
  • Use describeChild whenever the child has visible text or a label of its own. See the table above.
  • Make the child focusable. A tooltip on a plain <span> or icon needs tabIndex={0} and an aria-label, or keyboard users never see it.

Do and don't

  • Do keep tooltip text short: a name, or one sentence.
  • Do use a tooltip to say why a control is disabled.
  • Do use displayTooltipOnOverflow for names that may be cut short in tables and cards.
  • Don't put links, buttons or form fields inside a tooltip. It closes as the pointer moves towards them.
  • Don't repeat the visible label in the tooltip. "Refresh data" does not need a tooltip saying "Refresh data".
  • Don't restyle it with styles. Every tooltip in the product should look the same.

On this page