On this page
Overlays

Tooltip released

A sentence about a control — its description, or its name when it has none of its own.

  • Import@pacit/components/tooltip
  • Selector[pctTooltip]

Implements the W3C ARIA APG Tooltip pattern ↗

tooltip.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctTooltip } from '@pacit/components/tooltip';
ExportedWhat it is
PctTooltipPanelcomponentpct-tooltipThe panel a tooltip draws — attached to an overlay by PctTooltip, never written in a template. It is public because it is what the part inventory names (req-api-parts): the panel part a consumer styles belongs to this class.
PctTooltipdirective[pctTooltip]A tooltip: a short piece of text about the control it sits on, shown on hover, on keyboard focus and on a long press.
PctTooltipAstype'description' | 'name'What the tooltip's text IS to the control it hangs on — the distinction this component exists to force, and the one a title attribute never makes.
<button pctButton pctTooltip="Runs every check first">Verify</button>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

Placement, and what the sentence is

pctTooltipPlacement asks for a side — logical, so end is the right in this page and the left in an Arabic one. pctTooltipAs="name" makes the sentence the control's accessible name instead of its description — for an icon-only button that has no text of its own.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

A tooltip: a short piece of text about the control it sits on, shown on hover, on keyboard focus and on a long press.

Inputs

NameTypeWhat it does
textdefault ''stringThe text. An empty one is a tooltip that never opens rather than an empty panel.
placementdefault 'top'PctPlacementWhich side of the control it opens on — logical, so end is the right in an English page and the left in an Arabic one. The window has the last word: a side with no room for the panel falls back to the one across the control (pctPlacementPositions).
asdefault 'description'PctTooltipAsWhether the text describes the control or names it. See PctTooltipAs.
disableddefault falsebooleanSwitches the tooltip off without taking it out of the template — the state a control has while its own text is not worth showing. A disabled tooltip also stops naming: a name that is not shown is still a name, and one an application asked to be rid of is not.

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctTooltipHarness, PctTooltipPanelHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
panel[data-pct-part="panel"]the bubble with the sentence

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-tooltip-bg{pct.surface-inverse}#1e293b · #f1f5f9Background of the tooltip.
--pct-tooltip-fg{pct.on-surface-inverse}#ffffff · #0f172aText colour of the tooltip.
--pct-tooltip-font-size{pct.font.size.sm}13pxFont size of the tooltip.
--pct-tooltip-max-width280pxMaximum width of the tooltip.
--pct-tooltip-padding-x{pct.space.3}8pxHorizontal padding of the tooltip.
--pct-tooltip-padding-y{pct.space.2}4pxVertical padding of the tooltip.
--pct-tooltip-radius{pct.radius.md}8pxCorner radius of the tooltip.
--pct-tooltip-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the tooltip.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-tooltip-bg: #134e4a;
  --pct-tooltip-fg: #f0fdfa;
}
Accessibility

Measured, not assumed

Pattern: Tooltip — role="tooltip" on a panel that takes no focus, reached from the trigger by an ARIA relation, dismissible with Escape while focus stays where it was

Keyboard

keyeffecttest
Escapedismisses it, focus stays where it was (WCAG 1.4.13)apps/sandbox-e2e/src/tooltip.spec.ts
Tabopens it on arrival, closes it on leaving — never enters the panelapps/sandbox-e2e/src/tooltip.spec.ts

The panel is not in the Tab order and holds nothing focusable: it is read where it stands, and the control keeps focus the whole time.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/tooltip/src/tooltip.ts — the pattern named in the class comment
MeasuredKeyboard map testedapps/sandbox-e2e/src/tooltip.spec.ts — Escape and the Tab arrival in three engines; libs/components/tooltip/src/tooltip.spec.ts for what a focus that is not visible does
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /tooltip view, and separately an open tooltip over the icon-only button: the one place a violation would be real rather than a sandbox artefact (lesson-65)
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — tooltip-open, the card rather than the panel: what this component draws is a surface answering the page's own, and a picture of the panel alone would say nothing about the contrast between them
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the mode takes the inversion away (both surfaces become Canvas), so the panel draws a CanvasText edge there and only there
Measuredprefers-reduced-motionapps/sandbox-e2e/src/tooltip.spec.ts — the duration read from the panel is the reduced one, and the leave still ends. The first component here with a real enter/leave, which is what the dialog's card said would arrive with it
By designTouch target ≥ 24×24 pxnone — deliberately: the tooltip draws no target. It hangs on somebody else's control, and that control's size is that control's promise
By designSize axisnone — deliberately: a tooltip has no control height. req-api-size is about --pct-control-height-*; the only width here is --pct-tooltip-max-width, which a consumer overrides
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/tooltip.spec.ts — end measured on both sides of the writing direction. The inline gap is the one thing a screenshot would not catch: the dependency resolves start/end by direction and adds the offset as plain pixels afterwards, so the sign is flipped in pctPlacementPositions
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /tooltip view renders and hydrates with no NG05xx; a closed tooltip contributes no markup
By designFormsnone — deliberately: a tooltip is not a form control. It holds no value and implements no FormValueControl
MeasuredHover, focus and touch parityapps/sandbox-e2e/src/tooltip.spec.ts — the pointer, the keyboard (and a click, which opens nothing) and a long press; libs/components/tooltip/src/tooltip.spec.ts for the gestures' timers
MeasuredWCAG 1.4.13 — hoverableapps/sandbox-e2e/src/tooltip.spec.ts — the pointer travels onto the panel and it stays. The panel stands 8 px from the control, so reaching it means leaving the control, and the grace period is what keeps the text there
MeasuredThe leave is waited outapps/sandbox-e2e/src/tooltip.spec.ts (the panel outlives the decision to close, measured in three engines) + libs/components/core/src/core.spec.ts (pctAfterTransition: the event, the timeout floor and the cancel)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — one pair, and the only measurement pct.surface-inverse gets anywhere in the skin
By designStrings through PCT_TEXTSnone — deliberately: the component writes no string of its own
MeasuredSize budgetlibs/components/size.snapshot.md — the ./tooltip row, with the ./core row beside it
MeasuredScreen-reader logRead 2026-10-12 from docs/acr/at/ — three readers, whole sandbox, and this one is answered. A described control takes focus and all three say the description, after the name and the role: Orca Publish · button. · Runs every check before publishing., NVDA Publish, button, Runs every check before publishing, and VoiceOver, which reaches the other described button first, as in the passes of 2026-10-08 (dd0f02fa) and 2026-10-29, Delete the project Removes the project and everything in it button. The undescribed button beside it reads Approve the release, button and nothing more, so the description is attributable to the tooltip and not to the page
Measureddocs page/components/tooltip on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

72.73%mutation score208 of 286 mutants killed
13 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
1colour pairs measuredlight and dark, at every token build
1visual baselinesChromium, the sandbox's own pictures
2elements in the preview7 listeners · 1 render to settle · 2153 µs in jsdom, held exactly but for the clock

Why it is built this way

The component exists for one distinction a title attribute never makes: what the sentence is. A control that already has a name gets a description; a control that has none — the icon-only button — gets its name from here, and a name may not come and go with the pointer (0030).

Decisions behind it

  • 0030A name is an attribute, a description is a reference
  • 0024The overlay layer carries what an overlay severs, and borrows the closing stack
  • 0025A panel says whether it takes focus, and the rest of the focus layer waits for a consumer
  • 0013No headless core / skin split
  • lesson-65An audit of the rendered page measures the pages you wrote, not the component you shipped

Known limitations

  • A description exists only while the panel does. A screen-reader user who never focuses the control — reading the page with a virtual cursor — is not given it. The alternative is a hidden copy of the text living in the DOM for good, which is a second rendering of the same string to keep in step; the road chosen is written down in 0030, and it is the reason a tooltip may never carry something the page says nowhere else.
  • A disabled control does not answer. <button disabled> fires no pointer events in any engine, so a tooltip on one never opens. A control that has to explain why it is disabled needs a wrapper around it — the wrapper is the trigger.
  • The long press cannot suppress the platform's own gesture. Holding a finger on a control may still raise the system's text-selection callout, because the trigger is the consumer's element and this library has no stylesheet that reaches it.
  • No arrow. A pointer drawn from the panel to the control is one more thing to position and to repaint on every flip; the 8 px gap and the placement say the same thing more cheaply.
  • One tooltip at a time per trigger, and no delay knobs. The waits (150 ms in, 150 ms grace, 500 ms long press, 1500 ms after a finger) are constants. They are the numbers a hover feels right at, and an input for each would be four more things to get wrong for a case nobody has yet.