Tooltip
@ailuracode/alpine-tooltip
Headless tooltip store. Hover/focus triggers, open/close delays, Escape dismiss. Pair with @alpinejs/anchor for placement.
Install
pnpm add @ailuracode/alpine-tooltip @ailuracode/alpine-core alpinejsPositioning (Floating UI via Alpine):
pnpm add @alpinejs/anchorQuick start
import Alpine from "alpinejs";import { tooltipPlugin } from "@ailuracode/alpine-tooltip";
Alpine.plugin(tooltipPlugin());Alpine.start();Store API
| Method | Description |
|---|---|
open(id) / close(id) / toggle(id) |
Visibility |
isOpen(id) |
Open state |
register(id, options?) |
Configure delays and lifecycle callbacks |
showOnHover(id) / hideOnHover(id) |
Hover helpers |
showOnFocus(id) / hideOnFocus(id) |
Focus helpers |
handleKeydown(id, event) |
Escape dismiss |
Options per tooltip
| Option | Default | Description |
|---|---|---|
openDelay |
0 |
ms before opening on hover/focus |
closeDelay |
0 |
ms before closing on mouseleave/blur |
onOpen / onClose |
— | Lifecycle callbacks |
Architecture
TooltipController owns all mutable tooltip state. The Alpine plugin copies snapshots into $store.tooltip.instances on each change event. Mutating store snapshots directly does not change controller state.
Avoiding name collisions
If your application already owns a $store.tooltip — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(tooltipPlugin({ storeKey: "hints" })); // → $store.hintsThe exposed constant DEFAULT_TOOLTIP_STORE_KEY keeps the rename discoverable from TypeScript.
Standalone usage (no Alpine)
import { createTooltipController, createTooltipStore, createTooltipStoreFromController,} from "@ailuracode/alpine-tooltip";
const controller = createTooltipController();controller.register("help", { openDelay: 150 });controller.open("help");
const store = createTooltipStore();// or: createTooltipStoreFromController(controller)| Controller API | Description |
|---|---|
hasInstance(id) |
Whether a tooltip id is registered |
snapshotInstances() |
Shallow readonly copies for adapter sync |
isOpen(id) |
Query open state |
Migration
| Removed / changed | Replacement |
|---|---|
controller.instances getter |
snapshotInstances() or hasInstance(id) |
controller.toStore() |
createTooltipStore() or createTooltipStoreFromController(controller) |
Basic markup
<div x-data x-init="$store.tooltip.register('help', { openDelay: 150 })" @keydown.window="$store.tooltip.isOpen('help') && $store.tooltip.handleKeydown('help', $event)"> <button x-ref="helpAnchor" @mouseenter="$store.tooltip.showOnHover('help')" @mouseleave="$store.tooltip.hideOnHover('help')" @focus="$store.tooltip.showOnFocus('help')" @blur="$store.tooltip.hideOnFocus('help')" aria-describedby="help-tooltip" > Help </button>
<template x-teleport="body"> <div id="help-tooltip" x-show="$store.tooltip.isOpen('help')" x-anchor.top.fixed.offset.8="$refs.helpAnchor" role="tooltip" class="z-50" > Tooltip content </div> </template></div>SSR
Delays require client-side timers — initialize on the client via x-init.
Limitations
- Placement is your responsibility — use
@alpinejs/anchor(x-anchor.*.fixed) for flip, shift, and scroll tracking - Use
<template x-teleport="body">+x-anchor.fixedwhen the floating node sits insideoverflow-hiddenancestors - Wire
@keydown.windowwhile open so Escape works when focus stays on the trigger
License
MIT
