<bpdm/ui />
Overlay

Tooltip

A hover/focus tooltip — accessible, portaled, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Tooltip shows a short label on hover or focus, built on Radix — keyboard-accessible, screen-reader friendly, portaled, and theme-aware. Zero-config: wrap a trigger and pass content. Wrap the app in TooltipProvider only if you want a shared open delay. In Angular it's the [bpdmTooltip] directive.

Usage

tooltip-usage.tsx
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function CopyButton() {
  return (
    <Tooltip content="Copy address">
      <Button variant="secondary" appearance="outline">Copy</Button>
    </Tooltip>
  );
}
copy-button.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'copy-button',
  imports: [BpdmTooltip, BpdmButton],
  template: `<button bpdmButton variant="secondary" appearance="outline" bpdmTooltip="Copy address">Copy</button>`,
})
export class CopyButton {}

Sides

The side prop (top — default — right, bottom, left) sets where it appears; it's collision-aware and flips to stay in view.

tooltip-sides.tsx
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function Sides() {
  return (
    <>
      <Tooltip side="top" content="On the top"><Button>Top</Button></Tooltip>
      <Tooltip side="right" content="On the right"><Button>Right</Button></Tooltip>
      <Tooltip side="bottom" content="On the bottom"><Button>Bottom</Button></Tooltip>
      <Tooltip side="left" content="On the left"><Button>Left</Button></Tooltip>
    </>
  );
}
sides.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'sides',
  imports: [BpdmTooltip, BpdmButton],
  templateUrl: './sides.html',
})
export class Sides {}
sides.html
<button bpdmButton bpdmTooltip="On the top" bpdmTooltipSide="top">Top</button>
<button bpdmButton bpdmTooltip="On the right" bpdmTooltipSide="right">Right</button>
<button bpdmButton bpdmTooltip="On the bottom" bpdmTooltipSide="bottom">Bottom</button>
<button bpdmButton bpdmTooltip="On the left" bpdmTooltipSide="left">Left</button>

On an icon button

The most common use — naming an icon-only control so it's clear (and accessible).

tooltip-icon.tsx
import { Bell } from 'lucide-react';
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function NotificationsButton() {
  return (
    <Tooltip content="Notifications">
      <Button size="icon" variant="secondary" appearance="ghost" aria-label="Notifications">
        <Bell />
      </Button>
    </Tooltip>
  );
}
notifications-button.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'notifications-button',
  imports: [BpdmTooltip, BpdmButton],
  templateUrl: './notifications-button.html',
})
export class NotificationsButton {}
notifications-button.html
<button bpdmButton size="icon" variant="secondary" appearance="ghost" aria-label="Notifications" bpdmTooltip="Notifications">
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
    <path d="M10.268 21a2 2 0 0 0 3.464 0" />
    <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
  </svg>
</button>

Rich content

content accepts any node — a heading, shortcut hint, or small block — not just a string.

tooltip-rich.tsx
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function RichTooltip() {
  return (
    <Tooltip
      content={
        <div className="space-y-1">
          <p className="font-medium">Keyboard shortcut</p>
          <p className="text-muted-foreground">Press ⌘K to open search.</p>
        </div>
      }
    >
      <Button variant="secondary" appearance="outline">Rich content</Button>
    </Tooltip>
  );
}

Pass a TemplateRef for rich content.

rich-tooltip.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'rich-tooltip',
  imports: [BpdmTooltip, BpdmButton],
  templateUrl: './rich-tooltip.html',
})
export class RichTooltip {}
rich-tooltip.html
<button bpdmButton variant="secondary" appearance="outline" [bpdmTooltip]="rich">Rich content</button>

<ng-template #rich>
  <div class="space-y-1">
    <p class="font-medium">Keyboard shortcut</p>
    <p class="text-muted-foreground">Press ⌘K to open search.</p>
  </div>
</ng-template>

On a disabled trigger

A disabled control emits no hover/focus events, so a tooltip can't open on it directly. bpdm handles this — pass a disabled child and it's wrapped in a focusable element, so the "why is this disabled?" tooltip still works and stays keyboard-reachable.

tooltip-disabled-trigger.tsx
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function PublishButton() {
  return (
    <Tooltip content="You need the Admin role to publish">
      <Button disabled>Publish</Button>
    </Tooltip>
  );
}

In Angular, put the directive on a focusable wrapper and disable the child:

publish-button.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'publish-button',
  imports: [BpdmTooltip, BpdmButton],
  templateUrl: './publish-button.html',
})
export class PublishButton {}
publish-button.html
<span bpdmTooltip="You need the Admin role to publish" tabindex="0" class="inline-flex">
  <button bpdmButton disabled class="pointer-events-none">Publish</button>
</span>

Internationalization

The Tooltip carries no built-in copy of its own — the content is entirely author-supplied. So you localise a Tooltip by localising the content you pass into it; there is no messages prop to override (like the Popover, and unlike the Dialog or Drawer).

Under dir="rtl" the Tooltip is direction-aware with no extra prop: side="left" / side="right" and the collision-flip side mirror automatically (React via Radix, Angular via the CDK overlay's directionality), and the bubble's padding and arrow stay symmetric. side remains a physical edge you choose explicitly.

The example below simply passes a translated content string — confining the localised copy to your own content:

tooltip-i18n.tsx
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';

export function CopyButton() {
  return (
    // the tooltip text is your own content — pass a translated string
    <Tooltip content="Adresse kopieren">
      <Button variant="secondary" appearance="outline">Kopieren</Button>
    </Tooltip>
  );
}
copy-button.ts
import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'copy-button',
  imports: [BpdmTooltip, BpdmButton],
  // the tooltip text is your own content — pass a translated string
  template: `<button bpdmButton variant="secondary" appearance="outline" bpdmTooltip="Adresse kopieren">Kopieren</button>`,
})
export class CopyButton {}

See the Internationalization guide for the shared conventions.

API

PropTypeDefaultDescription
contentReactNodeWhat the tooltip shows. Empty/nullish → the child renders with no tooltip. Angular: [bpdmTooltip] (a string or a TemplateRef for rich content).
childrenReactNodeReact — the trigger (a single focusable element). Angular: the host element carrying [bpdmTooltip].
sidetop | right | bottom | lefttopEdge it appears on (collision-aware). Angular: bpdmTooltipSide.
alignstart | center | endcenterAlignment along the side. Angular: bpdmTooltipAlign.
delayDurationnumber200Delay before opening, in ms. Angular: bpdmTooltipDelay.
sideOffsetnumber6Gap from the trigger, in px. Angular: bpdmTooltipOffset.
hideArrowbooleanfalseHide the little arrow. Angular: bpdmTooltipHideArrow.
disabledbooleanfalseTurn the tooltip off (the trigger stays fully interactive; nothing appears on hover/focus). Angular: bpdmTooltipDisabled.
open / defaultOpenbooleanReact — controlled / uncontrolled open.
onOpenChange(open: boolean) => voidReact — fires when the open state changes.
classNamestringExtra classes on the tooltip bubble. Angular: bpdmTooltipClass.

Wrap the app in TooltipProvider (React) only to share one delayDuration across tooltips — a standalone Tooltip works without it.

Accessibility

  • Built on Radix Tooltip (React) / the CDK overlay (Angular) — the bubble is a role="tooltip" and the trigger is wired to it via aria-describedby, so assistive tech reads the hint as the trigger's description.
  • It opens on hover and keyboard focus, and dismisses on blur / Esc — so it's reachable by keyboard, not hover-only.
  • The bubble is hoverable: moving the pointer off the trigger and onto the tooltip itself keeps it open, so you can read (or reach content in) a tooltip without it vanishing — in both React and Angular.
  • The tooltip supplements the trigger's own accessible name, it doesn't replace it: on an icon-only control keep its aria-label (the tooltip names it visually; the label names it for assistive tech).
  • For a disabled control, the trigger is wrapped in a focusable element (React does this automatically for a disabled child; in Angular put the directive on a focusable wrapper) so the "why is this disabled?" explanation stays perceivable and keyboard-reachable.
  • It's for supplementary hints — don't hide essential-only information or interactive content behind hover. For that use a Popover.
  • Everything mirrors under dir="rtl" with no extra prop — the alignment and the collision-flip side flip automatically.

On this page