Qure UI
Components

Tooltip

A short label revealed on hover or focus, for controls whose purpose is not obvious.

'use client'import { DownloadIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/qure/ui/tooltip'export default function TooltipDemo() {  return (    <Tooltip>      <TooltipTrigger        render={          <Button variant="tertiary" size="icon-md" aria-label="Export study">            <DownloadIcon />          </Button>        }      />      <TooltipContent>Export study as DICOM</TooltipContent>    </Tooltip>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/tooltip.json

Usage

import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
<Tooltip>
  <TooltipTrigger render={<Button size="icon-md" aria-label="Export"><DownloadIcon /></Button>} />
  <TooltipContent>Export study as DICOM</TooltipContent>
</Tooltip>

Tooltip provides its own context, so a single tooltip does not need a TooltipProvider around it. The trigger is your element, by way of render — one node, one set of handlers, no wrapper that swallows the layout.

A tooltip is not a label

It appears on hover and focus, which means it is unavailable on touch and gone the moment attention moves. Anything a person needs in order to decide belongs on the page.

{/* Wrong: the only name is in the tooltip */}
<TooltipTrigger render={<Button size="icon-md"><DownloadIcon /></Button>} />

{/* Right: named for assistive technology, tooltip adds detail */}
<TooltipTrigger render={<Button size="icon-md" aria-label="Export"><DownloadIcon /></Button>} />

Do not put a tooltip on plain text that is not focusable, do not put interactive content inside one — a link in a tooltip cannot be reached by keyboard before the tooltip closes — and do not use one to hold an error message. Errors belong in a Field, where they stay put and are announced.

Placement

'use client'import { Button } from '@/registry/qure/ui/button'import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/qure/ui/tooltip'const sides = ['top', 'right', 'bottom', 'left'] as constexport default function TooltipSide() {  return (    <div className="flex flex-wrap gap-2">      {sides.map(side => (        <Tooltip key={side}>          <TooltipTrigger render={<Button variant="secondary" size="sm">{side}</Button>} />          <TooltipContent side={side}>Opens on the {side}</TooltipContent>        </Tooltip>      ))}    </div>  )}

side and sideOffset are forwarded to the positioner. top is the default and the right choice for a toolbar along the bottom of a viewport; right suits a vertical rail, where a tooltip above the button covers the button above it. The positioner flips the side when there is no room, so side is a preference rather than a promise.

On a disabled control

Trigger on the button
Trigger on a wrapper
'use client'import { LockIcon, PenLineIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/qure/ui/tooltip'export default function TooltipDisabled() {  return (    <div className="flex flex-wrap items-center gap-6">      {/* Does nothing: a disabled button fires no pointer events, so the          trigger never hears about the hover. */}      <div className="flex flex-col items-center gap-2">        <Tooltip>          <TooltipTrigger            render={              <Button variant="secondary" disabled>                <PenLineIcon /> Amend report              </Button>            }          />          <TooltipContent>Never appears</TooltipContent>        </Tooltip>        <span className="text-muted-foreground text-xs">Trigger on the button</span>      </div>      {/* Works: the trigger is a focusable wrapper the pointer can reach, and          the button inside it stays genuinely disabled. */}      <div className="flex flex-col items-center gap-2">        <Tooltip>          <TooltipTrigger            render={<span tabIndex={0} className="inline-flex rounded-md" />}          >            <Button variant="secondary" disabled className="pointer-events-none">              <LockIcon /> Amend report            </Button>          </TooltipTrigger>          <TooltipContent>            Signed reports can only be amended by the reporting radiologist.          </TooltipContent>        </Tooltip>        <span className="text-muted-foreground text-xs">Trigger on a wrapper</span>      </div>    </div>  )}

A disabled <button> fires no pointer events, so a tooltip whose trigger is that button never opens — the left-hand example above is the bug, live. This surprises people regularly, and it is browser behaviour rather than anything Base UI could fix.

The fix is to make the trigger a focusable wrapper around the disabled button, with pointer-events-none on the button so the hover lands on the wrapper instead:

<Tooltip>
  <TooltipTrigger render={<span tabIndex={0} className="inline-flex rounded-md" />}>
    <Button disabled className="pointer-events-none">Amend report</Button>
  </TooltipTrigger>
  <TooltipContent>Signed reports can only be amended by the reporting radiologist.</TooltipContent>
</Tooltip>

The tabIndex={0} is the part worth keeping. A span is not focusable, and without it the explanation for a disabled control is reachable by pointer only — which is exactly the group of readers least likely to be able to guess why the button is dead. If the control is disabled for a reason the reader could act on, consider saying it on the page instead.

In a toolbar

'use client'import {  ContrastIcon, MoveIcon, RotateCwIcon, RulerIcon, ZoomInIcon,} from 'lucide-react'import { Toolbar, ToolbarButton, ToolbarGroup } from '@/registry/qure/ui/toolbar'import {  Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from '@/registry/qure/ui/tooltip'const tools = [  { id: 'pan', label: 'Pan', hint: 'Drag to move the image', icon: MoveIcon },  { id: 'zoom', label: 'Zoom', hint: 'Drag up and down to zoom', icon: ZoomInIcon },  { id: 'window', label: 'Window level', hint: 'Drag to change window and level', icon: ContrastIcon },  { id: 'measure', label: 'Measure', hint: 'Click two points for a distance in mm', icon: RulerIcon },  { id: 'rotate', label: 'Rotate', hint: 'Rotate 90° clockwise', icon: RotateCwIcon },]export default function TooltipToolbar() {  return (    /*      One provider around the row. Without it each tooltip waits its own 200ms      and moving along the toolbar is a series of small pauses; with it, the      first hint costs the delay and its neighbours open instantly.    */    <TooltipProvider delay={200} closeDelay={80}>      <Toolbar aria-label="Image tools">        <ToolbarGroup aria-label="Tool">          {tools.map(({ id, label, hint, icon: Icon }) => (            <Tooltip key={id}>              {/* The button keeps its own aria-label. The tooltip adds the                  explanation; it is not the name. */}              <TooltipTrigger render={<ToolbarButton aria-label={label}><Icon /></ToolbarButton>} />              <TooltipContent>{hint}</TooltipContent>            </Tooltip>          ))}        </ToolbarGroup>      </Toolbar>    </TooltipProvider>  )}

Icon buttons are where tooltips earn their place, and a toolbar is where the delay is felt. Without grouping, each tooltip waits its own 200ms and moving along the row is a series of small pauses. Wrap the row in a provider and the first hint costs the delay while its neighbours open instantly:

<TooltipProvider delay={200} closeDelay={80}>
  <Toolbar>{/* …a Tooltip per button… */}</Toolbar>
</TooltipProvider>

Tooltip only supplies a provider of its own when there is not one already, so an outer provider wins and a lone tooltip still works with nothing around it. Wrapping unconditionally is the obvious version and it is wrong — the inner provider shadows the outer one, and grouping silently never happens.

Revealing truncated text

AccessionStudy description
ACC-4471902
ACC-4471915
'use client'import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/qure/ui/tooltip'const rows = [  {    accession: 'ACC-4471902',    description: 'CT Thorax with contrast, high resolution reconstruction, expiratory phase',  },  {    accession: 'ACC-4471915',    description: 'MRI Brain with and without gadolinium, epilepsy protocol, thin coronals',  },]export default function TooltipTruncate() {  return (    <table className="w-full max-w-md table-fixed text-sm">      <thead>        <tr className="text-muted-foreground text-left">          <th className="w-36 pb-2 font-medium">Accession</th>          <th className="pb-2 font-medium">Study description</th>        </tr>      </thead>      <tbody>        {rows.map(row => (          <tr key={row.accession} className="border-t">            <td className="py-2 tabular-nums">{row.accession}</td>            <td className="py-2">              <Tooltip>                {/* The trigger has to be focusable for a keyboard reader to                    reach the full text, so it is a button rather than a span. */}                <TooltipTrigger                  render={                    <button                      type="button"                      className="block w-full cursor-default truncate text-left outline-none focus-visible:underline"                    >                      {row.description}                    </button>                  }                />                <TooltipContent>{row.description}</TooltipContent>              </Tooltip>            </td>          </tr>        ))}      </tbody>    </table>  )}

A study description clipped to a column width is the one case where a tooltip may carry the whole content rather than a hint, because nothing is being hidden — the text is on the page, just cut off. Make the truncated cell a button so it is focusable, otherwise the full text exists for pointer users only.

Styling

The popup is --background-neutral-default in both themes. A tooltip sits above the interface, and matching the page makes it read as part of it. It is capped at 260px wide, which is a hint's worth of text: if yours does not fit, it is not a tooltip.

Keyboard and touch

Focusing the trigger opens the tooltip with no delay, and Escape closes it while focus stays where it is. There is no touch equivalent of hover, so a tooltip is simply absent on a phone — plan the interface so that nothing is lost when it is.

Accessibility

The popup carries role="tooltip" and an id, and the trigger points at it with aria-describedby, so the hint is announced after the control's name.

Base UI 1.7 wires none of that. Its popup has no role and no id, and its trigger no aria-describedby — the tooltip is drawn on screen and is absent from the accessibility tree entirely. Our Tooltip adds the two attributes. If you build a tooltip out of the primitive directly, add them yourself, and check by reading the DOM rather than by trusting the component.

aria-describedby is present whether or not the tooltip is open. While it is closed the popup is unmounted and the reference resolves to nothing, which assistive technology ignores.

API Reference

Everything Base UI's Tooltip accepts. TooltipContent takes the popup's props plus two of the positioner's, which it forwards:

Prop

Type

On TooltipProvider:

Prop

Type

On Tooltip:

Prop

Type

On this page