forty-cdk
llms.txt

Primitives

Tooltip

A small floating label that describes its trigger on hover or focus, without ever taking focus itself.

forty-cdk/tooltip WAI-ARIA APG

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's aria-describedby description, 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

PropertyTypeDescription
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 attributeValues
data-stateopen | closed
data-disabledpresent | absent
data-reduced-motionpresent | absent

ForTooltipTrigger

ForTooltipTrigger has no inputs of its own and coordinates via the ForTooltip context.

Data attributeValues
data-stateopen | closed

ForTooltipContent

ForTooltipContent has no inputs of its own and coordinates via the ForTooltip context.

Data attributeValues
data-stateopen | closed
data-reduced-motionpresent | 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.

KeyLibrary fallbackMeaning
openDelay700ms before showing after hover/focus enters.
closeDelay300ms before hiding after hover/focus leaves.
skipDelayDuration300Window (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.
sideOffset8Main-axis gap (px) for tooltips that don't set sideOffset themselves.
collisionPadding8Collision-middleware padding (px) for tooltips that don't set it themselves.
arrowPadding0Arrow padding (px) for tooltips that don't set it themselves.
showOnOverflowfalseShow only when the trigger's text is truncated, for tooltips that don't set it themselves.
hoverableContenttrueAllow 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 resolved openDelay (instant when the delay is 0 or the scope's skip-delay window is active). It is a no-op while disabled, and a no-op under showOnOverflow when the trigger's own text is not truncated. These are the same gates a hover / focus open passes.
  • hide() schedules the close after the resolved closeDelay and 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 id on the trigger element is preserved (and used as the trigger id internally); the generated for-tooltip-trigger-* id is only assigned when the element has none. Anchors, aria-labelledby references, 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:

ElementCustom propertyType / rangeDirectionMeaning
[forTooltipContent]--for-floating-anchor-widthpxoutTrigger (reference) width.
[forTooltipContent]--for-floating-anchor-heightpxoutTrigger (reference) height.
[forTooltipContent]--for-floating-available-widthpxoutSpace available along the inline axis (floating-ui size middleware). Clamp with max-width.
[forTooltipContent]--for-floating-available-heightpxoutSpace available along the block axis. Clamp with max-height.
[forTooltipContent]--for-floating-content-transform-origin<origin> keywordsouttransform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger.
[forTooltipArrow]--for-floating-arrow-offsetpx (default 0px)inConsumer-set. How far the arrow pokes out past the bubble edge, typically a negative px (e.g. -4px).

[forTooltipContent] is portaled to document.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 prevents mousedown to keep the editor focused) does not swallow a later keyboard focus.
  • Portal: the content element is moved to document.body on 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: none is applied only when hoverableContent is set to false. By default (hoverableContent is true) 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. Set hoverableContent to false (per-instance or via provideForTooltipDefaults) 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.
  • hoverableContent lets the pointer move into the bubble without dismissing it, which is useful for descriptive text the user may want to select. It drops the default pointer-events: none while 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.
  • showOnOverflow gates 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] writes position: absolute, the floating-ui-resolved left / top, and var(--for-floating-arrow-offset, 0px) on the side opposite the bubble. Set --for-floating-arrow-offset on the arrow (or any ancestor) to control how far the arrow pokes out. The offset is typically a negative px value such as -4px. Defaults to 0px.

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.