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
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>
);
}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.
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>
</>
);
}import { Component } from '@angular/core';
import { BpdmTooltip, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'sides',
imports: [BpdmTooltip, BpdmButton],
templateUrl: './sides.html',
})
export class Sides {}<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).
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>
);
}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 {}<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.
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.
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 {}<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.
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:
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 {}<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:
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>
);
}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
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactNode | — | What the tooltip shows. Empty/nullish → the child renders with no tooltip. Angular: [bpdmTooltip] (a string or a TemplateRef for rich content). |
children | ReactNode | — | React — the trigger (a single focusable element). Angular: the host element carrying [bpdmTooltip]. |
side | top | right | bottom | left | top | Edge it appears on (collision-aware). Angular: bpdmTooltipSide. |
align | start | center | end | center | Alignment along the side. Angular: bpdmTooltipAlign. |
delayDuration | number | 200 | Delay before opening, in ms. Angular: bpdmTooltipDelay. |
sideOffset | number | 6 | Gap from the trigger, in px. Angular: bpdmTooltipOffset. |
hideArrow | boolean | false | Hide the little arrow. Angular: bpdmTooltipHideArrow. |
disabled | boolean | false | Turn the tooltip off (the trigger stays fully interactive; nothing appears on hover/focus). Angular: bpdmTooltipDisabled. |
open / defaultOpen | boolean | — | React — controlled / uncontrolled open. |
onOpenChange | (open: boolean) => void | — | React — fires when the open state changes. |
className | string | — | Extra 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 viaaria-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
disabledchild; 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.