Primitives
Tooltip
A small floating label that describes its trigger on hover or focus, without ever taking focus itself.
Hover or focus the trigger and wait out the delay. The tip opens with data-state, and Escape closes it without moving focus.
Hover the button, or Tab to it — focus opens the tooltip too.
Hover / focus delays, Escape-to-dismiss, portal rendering, and @floating-ui/dom-driven positioning are built in.
New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the
@if/ open-state model and the portal → global CSS rule.
APG: tooltips are for non-interactive descriptive text. If you need a click-to-open menu / popup with focusable contents, use a Popover primitive.
When to choose
- Tooltip:
role="tooltip", opened on hover or focus and never focusable itself. While open it becomes the trigger'saria-describedbydescription, so its content must be non-interactive text. - Hover Card: the same open-on-dwell cadence, but its content may hold links and buttons and it names nothing. Choose it when the surface is a preview to read or click, not a description of the trigger.
- Popover: opens on activation and moves focus into the surface. Choose it whenever the content contains anything the user has to operate.
Anatomy
<span forTooltip #tip="forTooltip" side="top" [openDelay]="400">
<button forTooltipTrigger type="button" aria-label="Save">💾</button>
<!-- @if (tip.open()) { -->
<div forTooltipContent class="my-tooltip">
Save changes
<span forTooltipArrow class="my-tooltip-arrow"></span>
</div>
<!-- } -->
</span>
Examples
Overflow-only
With showOnOverflow the tooltip opens only when the trigger's own text is actually truncated, which makes showOnOverflow ideal for table cells or file paths that may or may not fit. The short label fits and stays silent; the long one is clipped, so the full text appears.
Hover each chip. The short label fits, so no tooltip opens; the long one is clipped, so the full text appears.
Hoverable content
With hoverableContent the bubble keeps pointer-events, so the pointer can rest on it to read or select long text without dismissing it. A pointer-grace safe triangle bridges the trigger-to-content gap. The content must still stay non-interactive per APG.
With hoverableContent the bubble keeps pointer-events, so you can move onto it to read or select the text without it closing.
API
ForTooltip
| Property | Type | Description |
|---|---|---|
open | model | Two-way bindable visibility. Default: — |
side | input | Anchor side ('top' / 'right' / 'bottom' / 'left'). Falls back to provideForTooltipDefaults ('top').Default: — |
align | input | Alignment along side ('start' / 'center' / 'end'). Falls back to provideForTooltipDefaults ('center').Default: — |
sideOffset | input | Gap (px) between trigger and content along the main axis. Falls back to provideForTooltipDefaults (8).Default: — |
alignOffset | input | Gap (px) along the cross axis. Default: 0 |
collisionPadding | input | Padding (px) for the flip / shift / size collision middlewares. Falls back to provideForTooltipDefaults (8).Default: — |
arrowPadding | input | Padding (px) keeping [forTooltipArrow] clear of the content edges. Falls back to provideForTooltipDefaults (0).Default: — |
openDelay | input | ms before showing after hover/focus enters. Falls back to provideForTooltipDefaults (700).Default: — |
closeDelay | input | ms before hiding after hover/focus leaves. Escape ignores this. Falls back to provideForTooltipDefaults (300).Default: — |
disabled | input | When true, all interaction is ignored.Default: — |
showOnOverflow | input | Show only when the trigger's own text is truncated (scrollWidth > clientWidth). Falls back to provideForTooltipDefaults (false).Default: — |
hoverableContent | input | Let the pointer move into the content without dismissing it (drops pointer-events: none while open). Falls back to provideForTooltipDefaults (true).Default: — |
escapeKeyDown | OutputEmitterRef | Output. Fires when Escape is pressed while the tooltip is open, wherever focus lives. Call preventDefault() on the emitted veto to keep it open; the native KeyboardEvent is on .event.Default: — |
openChange | OutputEmitterRef | Output. Implicit from model(). Emits only on internal transitions (delay timers, Escape, a press on the trigger, scroll, and the force-close that runs when disabled flips to true), not on consumer writes via [(open)].Default: — |
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present | absent | |
data-reduced-motion | present | absent |
ForTooltipTrigger
ForTooltipTrigger has no inputs of its own and coordinates via the ForTooltip context.
| Data attribute | Values | |
|---|---|---|
data-state | open | closed |
ForTooltipContent
ForTooltipContent has no inputs of its own and coordinates via the ForTooltip context.
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-reduced-motion | present | absent |
ForTooltipArrow
ForTooltipArrow has no inputs of its own and coordinates via the ForTooltip context.
Scoped defaults
provideForTooltipDefaults configures defaults for an injector subtree, whether at the application root or in any component's providers array. Partial overrides inherit unspecified keys from the parent scope (or the library fallbacks at the root). Peer tooltips that share a skip-delay window open instantly when one of them closes and the next is hovered within skipDelayDuration.
| Key | Library fallback | Meaning | |
|---|---|---|---|
openDelay | 700 | ms before showing after hover/focus enters. | |
closeDelay | 300 | ms before hiding after hover/focus leaves. | |
skipDelayDuration | 300 | Window (ms) after a peer closes during which the next open is instant. | |
side | 'top' | Anchor side for tooltips that don't set side themselves. | |
align | 'center' | Alignment along side for tooltips that don't set align themselves. | |
sideOffset | 8 | Main-axis gap (px) for tooltips that don't set sideOffset themselves. | |
collisionPadding | 8 | Collision-middleware padding (px) for tooltips that don't set it themselves. | |
arrowPadding | 0 | Arrow padding (px) for tooltips that don't set it themselves. | |
showOnOverflow | false | Show only when the trigger's text is truncated, for tooltips that don't set it themselves. | |
hoverableContent | true | Allow hovering into the content, for tooltips that don't set it themselves. |
Per-instance inputs always win over the scope defaults.
A scoped call starts its own skip-delay window only when it sets openDelay, closeDelay or skipDelayDuration. A call that changes only placement or behaviour keys (side, sideOffset, showOnOverflow, …) joins the window of its parent scope, so moving from a tooltip outside the scope to one inside it still opens instantly. Pass { skipDelayScope: 'own' } or { skipDelayScope: 'inherit' } as the second argument to choose either way. A scope that joins its parent's window keeps the parent's skipDelayDuration for it, while its own openDelay and closeDelay still apply.
import { provideForTooltipDefaults } from 'forty-cdk/defaults';
// Bottom-anchored tooltips app-wide
bootstrapApplication(App, {
providers: [provideForTooltipDefaults({ side: 'bottom', sideOffset: 4 })],
});
// component-level override layers on top, per key
@Component({
providers: [provideForTooltipDefaults({ openDelay: 200 })],
...
})
class Toolbar {}
// placement-only override, sharing the parent scope's skip-delay window
@Component({
providers: [provideForTooltipDefaults({ side: 'left' })],
...
})
class Sidebar {}
// a separate window without changing any timing
@Component({
providers: [provideForTooltipDefaults({}, { skipDelayScope: 'own' })],
...
})
class Panel {}
Imperative show and hide
For programmatic control beyond hover and focus (e.g. a wrapper that drives the tooltip from a text-truncation observer), ForTooltip exposes show() and hide() methods. Grab the root with a template reference (#tip="forTooltip") and call them:
import { Component } from '@angular/core';
import { ForTooltip, ForTooltipContent, ForTooltipTrigger } from 'forty-cdk/tooltip';
@Component({
selector: 'demo-imperative',
imports: [ForTooltip, ForTooltipTrigger, ForTooltipContent],
template: `
<span forTooltip #tip="forTooltip">
<button type="button" forTooltipTrigger>Save</button>
@if (tip.open()) {
<div forTooltipContent class="my-tooltip">Save changes</div>
}
</span>
<button type="button" (click)="tip.show()">Show</button>
<button type="button" (click)="tip.hide()">Hide</button>
`,
})
export class DemoImperative {}
Both mirror the hover / focus lifecycle rather than bypassing it:
show()schedules the open after the resolvedopenDelay(instant when the delay is0or the scope's skip-delay window is active). It is a no-op whiledisabled, and a no-op undershowOnOverflowwhen the trigger's own text is not truncated. These are the same gates a hover / focus open passes.hide()schedules the close after the resolvedcloseDelayand disarms the hoverable-content grace bridge.
For an instant, unconditional open or close that ignores the delays and both gates, write the [(open)] model directly (open.set(true) / open.set(false)) instead. To suppress empty-message tooltips, keep using the disabled input shown above rather than gating the show() call yourself.
Keyboard
- Tab to the trigger → opens the tooltip after
openDelay. The scroll that brings an off-screen trigger into view does not cancel it. - Tab away → closes after
closeDelay. - Escape while open → closes immediately, regardless of
closeDelay.
Accessibility
Implements the WAI-ARIA Tooltip pattern.
- The trigger receives
aria-describedby="<content-id>"only while the tooltip is open, matching APG. - A consumer-set
idon the trigger element is preserved (and used as the trigger id internally); the generatedfor-tooltip-trigger-*id is only assigned when the element has none. Anchors,aria-labelledbyreferences, and<label for>associations keep working. - The content carries
role="tooltip"and a stable id wired to the trigger. - The optional arrow is
aria-hidden="true"because it's purely decorative. - The tooltip never steals focus.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
CSS custom properties
[forTooltipContent] is portaled to document.body and gets its position resolved by floating-ui. It exposes that geometry as custom properties on the content host (cleared on close), and [forTooltipArrow] reads the consumer-settable --for-floating-arrow-offset:
| Element | Custom property | Type / range | Direction | Meaning | |
|---|---|---|---|---|---|
[forTooltipContent] | --for-floating-anchor-width | px | out | Trigger (reference) width. | |
[forTooltipContent] | --for-floating-anchor-height | px | out | Trigger (reference) height. | |
[forTooltipContent] | --for-floating-available-width | px | out | Space available along the inline axis (floating-ui size middleware). Clamp with max-width. | |
[forTooltipContent] | --for-floating-available-height | px | out | Space available along the block axis. Clamp with max-height. | |
[forTooltipContent] | --for-floating-content-transform-origin | <origin> keywords | out | transform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger. | |
[forTooltipArrow] | --for-floating-arrow-offset | px (default 0px) | in | Consumer-set. How far the arrow pokes out past the bubble edge, typically a negative px (e.g. -4px). |
[forTooltipContent]is portaled todocument.body, so styles scoped to the[forTooltip]wrapper won't reach it. Style the bubble with a global stylesheet or a class on the content directive itself. See Styling floating content for the full positioner custom-property list (--for-floating-anchor-width/-height,--for-floating-available-width/-height,--for-floating-content-transform-origin) and the animation / arrow recipes.
.my-tooltip {
opacity: 0;
transform: scale(0.9);
transform-origin: var(--for-floating-content-transform-origin);
transition:
opacity 120ms,
transform 120ms;
}
.my-tooltip[data-state='open'] {
opacity: 1;
transform: scale(1);
}
Reduced motion
[forTooltip] and [forTooltipContent] reflect data-reduced-motion (present / absent) whenever the OS prefers-reduced-motion: reduce media query matches, so you can opt your own transitions out without re-deriving the query in CSS or TypeScript. The attribute flips reactively if the preference changes mid-session.
.my-tooltip[data-reduced-motion] {
transition: none;
}
The tooltip's open / close delays are hover-intent debouncing rather than motion, so they are deliberately left unchanged under reduced motion. Only the visual transitions (which are yours) should opt out.
Tooltip content is template-provided and mounts via the consumer's own markup, so the tooltip cannot know the content would be empty before opening. It would happily open an empty bubble on hover/focus. The supported gate is the existing disabled input: drive it from whatever signal feeds the content. This is the recipe for design-system wrappers that take the tooltip text as a string input:
import { Component, input } from '@angular/core';
import { ForTooltip, ForTooltipContent, ForTooltipTrigger } from 'forty-cdk/tooltip';
@Component({
selector: 'my-tooltip-button',
imports: [ForTooltip, ForTooltipTrigger, ForTooltipContent],
template: `
<span forTooltip #tip="forTooltip" [disabled]="!message()">
<button type="button" forTooltipTrigger><ng-content /></button>
@if (tip.open()) {
<div forTooltipContent class="my-tooltip">{{ message() }}</div>
}
</span>
`,
})
export class MyTooltipButton {
readonly message = input('');
}
While disabled is true, hover and focus are ignored and an already-open tooltip force-closes, so there is no empty bubble and no stale aria-describedby.
Behavior notes
- Activating the trigger dismisses the tooltip. A press (
pointerdown) on the trigger closes an open tooltip immediately: the user is acting on the control, not asking for its description, so the bubble shouldn't cover the result of the click. The focus the same press induces does not reopen it (see below); the tooltip stays dismissed until the pointer leaves and re-enters, or the trigger is focused again from the keyboard. To keep the tooltip open across a click, drive[(open)]yourself. - Only keyboard focus opens via the focus path. Open-on-focus fires only when focus does not follow a pointer press on the trigger (i.e. a real keyboard
Tab). A mouse, pen, or touch press that focuses the trigger never opens (or reopens) the tooltip, because hover already covers pointer users. A press counts for a short window only, so a press that never focuses the trigger (a<button>in macOS Safari or Firefox, a toolbar that preventsmousedownto keep the editor focused) does not swallow a later keyboard focus. - Portal: the content element is moved to
document.bodyon first render. Any styles you scope to the wrapper won't reach it, so style the bubble globally or via a class on the content directive itself. pointer-events: noneis applied only whenhoverableContentis set tofalse. By default (hoverableContentistrue) the pointer may rest over the bubble (WCAG 2.1 SC 1.4.13 "Hoverable"), and clicks land on the bubble rather than passing through to whatever is behind. SethoverableContenttofalse(per-instance or viaprovideForTooltipDefaults) to restore the pass-through behavior, and keep the content non-interactive per APG regardless.- Keep content non-interactive. Tooltips don't trap focus and won't survive a click into them. APG explicitly forbids interactive children.
hoverableContentlets the pointer move into the bubble without dismissing it, which is useful for descriptive text the user may want to select. It drops the defaultpointer-events: nonewhile open and bridges the trigger / content gap with a pointer-grace "safe triangle" so a slow diagonal traversal doesn't close the tooltip. The content must still stay non-interactive per APG.showOnOverflowgates the tooltip on the trigger being truncated (scrollWidth > clientWidth). This is the common pattern for ellipsized labels, where the tooltip adds nothing once the full text already fits. When the trigger's text fits, hover and focus are ignored.- Closes on scroll. When a scroll container holding the trigger moves it under a stationary cursor (wheel / trackpad scrolling a virtualized or overflow-scroll list), an open tooltip closes immediately and hover opens stay suppressed for a short window while the scroll is in flight, so that tooltips on rows sliding past the pointer don't linger or flicker open. This is always on; a genuine pointer move after scrolling settles opens the tooltip normally again. A scroll that does not move the trigger (inside the bubble, or in an unrelated container such as an auto-scrolling log) leaves the tooltip open, and so does any scroll while the trigger holds focus, so a keyboard-opened tooltip survives the scroll that brings its trigger into view.
- Touch: APG flags tooltips as problematic on touch devices (no hover, no separate focus, no obvious dismiss). The trigger filters touch pointers out of both the hover-open and focus-open paths, so a tap does not open the tooltip. Only mouse hover and keyboard focus do. For touch-first UI where the descriptive content must be reachable on tap, consider a Popover.
- Arrow offset:
[forTooltipArrow]writesposition: absolute, the floating-ui-resolvedleft/top, andvar(--for-floating-arrow-offset, 0px)on the side opposite the bubble. Set--for-floating-arrow-offseton the arrow (or any ancestor) to control how far the arrow pokes out. The offset is typically a negativepxvalue such as-4px. Defaults to0px.
Triggers stamped from outside-declared templates
Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forTooltipTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab it with #root="forTooltip" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<span forTooltip #root="forTooltip">
<ng-container *ngTemplateOutlet="trig; context: { root }" />
@if (root.open()) {
<div forTooltipContent>Save changes</div>
}
</span>
<ng-template #trig let-root="root">
<button type="button" [forTooltipTrigger]="root" aria-label="Save">💾</button>
</ng-template>
Wrapping in a design system
Subclass the root and re-provide FOR_TOOLTIP_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.