Primitives
Hover Card
A floating card that opens on hover to preview the content behind a link, with a pointer bridge keeping it open.
Hover or focus the trigger and wait out the delay. The card opens with data-state="open", and leaving both the trigger and the card closes it again.
Article by @ada on the analytical engine.
Use it for any complementary information that surfaces on dwell, such as profile snapshots, link previews, or definition cards. There is no APG pattern for HoverCard. Treat it as a presentational layer: the trigger must already convey full meaning (it's a link, a name, a tag), so keyboard-only users miss nothing if they never see the card. Card content can be interactive. That's its main difference from [forTooltip], where APG bans it.
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.
When to choose
- Hover Card: a preview opened by hover or focus, whose content may be interactive. It adds no ARIA relationship to its trigger, so the card may only enrich what the trigger already conveys.
- Tooltip: the same hover / focus cadence, but the content is non-interactive and becomes the trigger's
aria-describedbydescription. Choose it when the text is the trigger's hint. - Popover: opens on activation rather than on hover, and focus moves into the surface. Choose it when the content must be reachable from the keyboard.
Anatomy
<span forHoverCard #card="forHoverCard" side="top">
<a forHoverCardTrigger href="/users/ada">Ada Lovelace</a>
<!-- @if (card.open()) { -->
<div forHoverCardContent animate.enter="card-in" animate.leave="card-out">
<h3>Ada Lovelace</h3>
<p>Mathematician — designed the first algorithm.</p>
<span forHoverCardArrow></span>
</div>
<!-- } -->
</span>
Examples
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ForHoverCard,
ForHoverCardArrow,
ForHoverCardContent,
ForHoverCardTrigger,
} from 'forty-cdk/hover-card';
@Component({
selector: 'app-hover-card-default-example',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ForHoverCard, ForHoverCardTrigger, ForHoverCardContent, ForHoverCardArrow],
template: `
<p class="hovercard-lead">
Article by
<span forHoverCard #card="forHoverCard" side="top">
<a forHoverCardTrigger class="hovercard-trigger" href="#ada">@ada</a>
@if (card.open()) {
<div forHoverCardContent class="hovercard" animate.enter="hovercard-pop-in">
<div class="hovercard-head">
<span class="hovercard-avatar" aria-hidden="true">AL</span>
<div class="hovercard-id">
<strong>Ada Lovelace</strong>
<span class="hovercard-handle">@ada</span>
</div>
</div>
<p class="hovercard-bio">
Mathematician and writer — wrote the first algorithm intended for a machine.
</p>
<div class="hovercard-stats">
<span><b>128</b> notes</span>
<span><b>1.8k</b> followers</span>
</div>
<button class="hovercard-follow" type="button">Follow</button>
<span forHoverCardArrow class="hovercard-arrow"></span>
</div>
}
</span>
on the analytical engine.
</p>
`,
})
export class HoverCardDefaultExample {}
API
ForHoverCard
| Property | Type | Description |
|---|---|---|
open | model | (openChange) fires only on internal transitions (delay timers, escape, blur, and the force-close that runs when disabled flips to true).Default: — |
side | input | Anchor side ('top' / 'right' / 'bottom' / 'left'). Falls back to provideForHoverCardDefaults ('top').Default: — |
align | input | Alignment along side ('start' / 'center' / 'end'). Falls back to provideForHoverCardDefaults ('center').Default: — |
sideOffset | input | Gap (px) between trigger and card along the main axis. Falls back to provideForHoverCardDefaults (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 provideForHoverCardDefaults (8).Default: — |
arrowPadding | input | Padding (px) keeping [forHoverCardArrow] clear of the content edges. Falls back to provideForHoverCardDefaults (0).Default: — |
openDelay | input | Per-card override for open delay. Falls back to provideForHoverCardDefaults (700ms).Default: — |
closeDelay | input | Per-card override for close delay. Falls back to provideForHoverCardDefaults (300ms).Default: — |
disabled | input | When true, hover / focus interaction is ignored AND any open card is force-closed (with (openChange) firing so a [(open)] binding stays in sync).Default: — |
escapeKeyDown | OutputEmitterRef | Output. Fires when Escape is pressed while the card is open, regardless of where focus currently lives (trigger, portaled content, or an unrelated element). Call preventDefault() on the emitted veto to keep it open; the native KeyboardEvent is on .event.Default: — |
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present | absent | |
data-reduced-motion | present | absent |
ForHoverCardTrigger
| Data attribute | Values | |
|---|---|---|
data-state | open | closed |
ForHoverCardContent
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-reduced-motion | present | absent |
ForHoverCardArrow
| Data attribute | Values | |
|---|---|---|
data-hover-card-arrow | present (always) |
Scoped defaults
provideForHoverCardDefaults configures defaults for an injector subtree, either 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 cards 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 cards that don't set side themselves. | |
align | 'center' | Alignment along side for cards that don't set align themselves. | |
sideOffset | 8 | Main-axis gap (px) for cards that don't set sideOffset themselves. | |
collisionPadding | 8 | Collision-middleware padding (px) for cards that don't set it themselves. | |
arrowPadding | 0 | Arrow padding (px) for cards 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, …) joins the window of its parent scope, so moving from a card 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.
The HoverCard coordinator is independent from TooltipCoordinator, because the two patterns have different cadences and shouldn't share their skip-delay windows.
import { provideForHoverCardDefaults } from 'forty-cdk/defaults';
// Right-aligned profile cards app-wide
bootstrapApplication(App, {
providers: [provideForHoverCardDefaults({ side: 'right', sideOffset: 4 })],
});
// component-level override layers on top, per key
@Component({
providers: [provideForHoverCardDefaults({ openDelay: 200 })],
...
})
class ProfileList {}
// placement-only override, sharing the parent scope's skip-delay window
@Component({
providers: [provideForHoverCardDefaults({ side: 'left' })],
...
})
class Sidebar {}
// a separate window without changing any timing
@Component({
providers: [provideForHoverCardDefaults({}, { skipDelayScope: 'own' })],
...
})
class Panel {}
Imperative show and hide
For programmatic control beyond hover and focus (e.g. a wrapper that opens the card from an external event), ForHoverCard exposes show() and hide() methods. Grab the root with a template reference (#card="forHoverCard") and call them:
import { Component } from '@angular/core';
import { ForHoverCard, ForHoverCardContent, ForHoverCardTrigger } from 'forty-cdk/hover-card';
@Component({
selector: 'demo-imperative',
imports: [ForHoverCard, ForHoverCardTrigger, ForHoverCardContent],
template: `
<span forHoverCard #card="forHoverCard">
<a forHoverCardTrigger href="/users/ada">Ada Lovelace</a>
@if (card.open()) {
<div forHoverCardContent>Mathematician — designed the first algorithm.</div>
}
</span>
<button type="button" (click)="card.show()">Show</button>
<button type="button" (click)="card.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 passes the same gates a hover open does: it is a no-op whiledisabled, and a no-op while an ancestor is scrolling (the scroll-dismiss suppression window).hide()schedules the close after the resolvedcloseDelayand disarms the pointer-grace bridge.
For an instant, unconditional open or close that ignores the delays and every gate, write the [(open)] model directly (open.set(true) / open.set(false)) instead.
Keyboard
- Tab to the trigger → opens the card after
openDelay. The scroll that brings an off-screen trigger into view does not cancel it. - Blur (Tab away) → closes the card after
closeDelay. - Escape while open → closes immediately, regardless of where focus currently lives (trigger, portaled content, or an unrelated element). Routed through a document-level dismissible layer that is active only while the card is open. Call
preventDefault()on the(escapeKeyDown)output to keep it open. When focus is inside the card, it returns to the trigger, and the card stays closed until the trigger is hovered or focused again.
Accessibility
- Not for tooltips. If your overlay is a non-interactive label / hint, use
[forTooltip]. HoverCard does not setaria-describedby; the trigger keeps its own label. - Trigger must stand alone. Keyboard users don't see hover-only previews. Make sure the trigger's text /
aria-labelalready describes its destination or action. - Focus opens the card so keyboard users get the preview when tabbing through a list. Blur closes it; Escape closes immediately, no matter where focus currently lives: on the trigger, on a link inside the content, or on an unrelated element (the common case for a card opened by hover). Escape is routed through a document-level dismissible layer that is active only while the card is open.
- Pointer interaction inside the card. Moving the cursor from the trigger to the content cancels the close timer, so users can copy text or follow nested links. A pointer-grace "safe triangle" bridges the gap between the trigger and the content: while the pointer travels across the default
sideOffsetgap toward the card it is assumed to be heading there, so the card stays open even whencloseDelayis0. This also holds when the content overlaps its trigger (a negativesideOffset). The card closes once the pointer leaves the safe triangle without reaching the card, or on blur / Escape / scroll. - Focus inside the card holds it open. Once one of its controls has focus (after clicking a button in it, say), the pointer leaving the card or a scroll does not close it; focus leaving the card closes it after
closeDelay. If the card closes with focus inside it, focus returns to the trigger rather than falling to the page. - Use
provideForHoverCardDefaultsper scope when you need a different cadence for, e.g., a list of profile cards (faster) vs. a sidebar of glossary entries (slower).
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
[forHoverCardContent] 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 [forHoverCardArrow] reads the consumer-settable --for-floating-arrow-offset:
| Element | Custom property | Type / range | Direction | Meaning | |
|---|---|---|---|---|---|
[forHoverCardContent] | --for-floating-anchor-width | px | out | Trigger (reference) width. | |
[forHoverCardContent] | --for-floating-anchor-height | px | out | Trigger (reference) height. | |
[forHoverCardContent] | --for-floating-available-width | px | out | Space available along the inline axis (floating-ui size middleware). Clamp with max-width. | |
[forHoverCardContent] | --for-floating-available-height | px | out | Space available along the block axis. Clamp with max-height. | |
[forHoverCardContent] | --for-floating-content-transform-origin | <origin> keywords | out | transform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger. | |
[forHoverCardArrow] | --for-floating-arrow-offset | px (default 0px) | in | Consumer-set. How far the arrow pokes out past the card edge, typically a negative px (e.g. -4px). |
[forHoverCardContent](and the projected[forHoverCardArrow]) is portaled todocument.body, so it sits outside your component's view-encapsulated styles. Style it with global CSS or a class you pass on the content element, because component-scoped styles won't reach it. The positioner also writes the shared geometry custom properties listed above (--for-floating-anchor-width/-height,--for-floating-available-width/-height,--for-floating-content-transform-origin); see Styling floating content for the full list and the side/align animation recipe.
.hovercard[data-state='open'] {
animation: card-in 120ms ease-out;
}
.hovercard {
max-width: var(--for-floating-available-width);
transform-origin: var(--for-floating-content-transform-origin);
}
Reduced motion
[forHoverCard] and [forHoverCardContent] 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.
.hovercard[data-reduced-motion] {
animation: none;
transition: none;
}
The card'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.
Behavior notes
- 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 card closes immediately and hover opens stay suppressed for a short window while the scroll is in flight, so that cards 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 card normally again. The keyboard-focus open path is never suppressed. A scroll that does not move the trigger (inside the card, or in an unrelated container) leaves the card open, and so does any scroll while focus is on the trigger or inside the card.
- Arrow offset:
[forHoverCardArrow]writesposition: absolute, the floating-ui-resolvedleft/top, andvar(--for-floating-arrow-offset, 0px)on the side opposite the card. Set--for-floating-arrow-offseton the arrow element (or any ancestor) to control how far the arrow pokes out. The value is typically a negativepxsuch as-4px. The helper ships no default visual.
Triggers stamped from outside-declared templates
Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forHoverCardTrigger] 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="forHoverCard" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<span forHoverCard #root="forHoverCard">
<ng-container *ngTemplateOutlet="trig; context: { root }" />
@if (root.open()) {
<div forHoverCardContent>…</div>
}
</span>
<ng-template #trig let-root="root">
<a [forHoverCardTrigger]="root" href="/users/ada">Ada Lovelace</a>
</ng-template>
Wrapping in a design system
Subclass the root and re-provide FOR_HOVER_CARD_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.