Positioning Tooltips and Popovers Correctly
Tooltips getting clipped at the edge of the viewport, dropdowns disappearing behind other elements, and popovers that stop tracking the scroll position: the x-anchor plugin from Alpine.js solves all of this with the Floating UI engine, automatic flip behavior and scroll awareness.
Table of Contents
- 1. The Problem: Manual Positioning Always Breaks Eventually
- 2. x-anchor: Installation and Core Principle
- 3. Placement Options: 12 Positions and Auto Flip
- 4. Tooltip Pattern: Hover-Based With Delay
- 5. Popover Pattern: Click-Based With Outside Close
- 6. Dropdown Menu With x-anchor
- 7. Offset, Arrow and Styling
- 8. Accessibility: ARIA Attributes for Tooltips and Popovers
- 9. x-anchor vs. Manual Positioning vs. Tippy.js
- 10. Summary
- 11. FAQ
1. The Problem: Manual Positioning Always Breaks Eventually
Positioning tooltips and popovers correctly is surprisingly difficult. The naive approach, an absolutely positioned element placed relative to the anchor element with CSS, works in most cases but fails in predictable scenarios: the tooltip gets clipped at the edge of the viewport because there is no room for the preferred position. The element scrolls out of view, but the popover stays behind. The anchor element sits inside a scrollable container, but the popover lives in the body and loses the reference. A CSS transform on a parent element breaks the absolute positioning entirely.
These problems are well known and already solved, by Floating UI, a small JavaScript library dedicated exclusively to positioning "floating" elements. Alpine.js integrates Floating UI through the x-anchor plugin: a directive that positions an element relative to an anchor element, with automatic flip behavior when there is no room, scroll awareness and viewport boundary checks. The result is tooltips and popovers that always behave correctly, no matter where on the page or in what scroll state.
2. x-anchor: Installation and Core Principle
The x-anchor plugin is a first-party Alpine.js plugin and, like every other plugin, is registered via Alpine.plugin(anchor). In NPM projects it is installed as @alpinejs/anchor. The plugin brings Floating UI along as a dependency, so there is no need to install Floating UI separately. Once registered, the x-anchor directive is available in every template.
The core principle of x-anchor is simple: the floating element receives the directive, which points to the anchor element. Alpine.js then calculates the position of the floating element relative to the anchor, takes the viewport and any scrollable container into account, and sets position: absolute along with the appropriate top and left values. The floating element must be positioned correctly in the DOM, either directly in the body or inside a container with position: relative and overflow: visible.
// 1. Register the plugin
import Alpine from 'alpinejs';
import anchor from '@alpinejs/anchor';
Alpine.plugin(anchor);
Alpine.start();
// 2. Basic usage in HTML
// The floating element references the anchor element via $refs
/*
<div x-data="{ open: false }">
<!-- Anchor element -->
<button x-ref="trigger" @click="open = !open">
Show info
</button>
<!-- Floating element: x-anchor references the anchor via $refs -->
<div
x-show="open"
x-anchor.bottom-start="$refs.trigger"
style="position: absolute; width: 200px;"
class="bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-50"
>
<p>This content appears below the button.</p>
<p>If there is not enough room, it automatically switches sides.</p>
</div>
</div>
*/
// 3. Placement as a modifier, all options:
// x-anchor.top -> above, horizontally centered
// x-anchor.top-start -> above, left aligned
// x-anchor.top-end -> above, right aligned
// x-anchor.bottom -> below, horizontally centered
// x-anchor.bottom-start -> below, left aligned
// x-anchor.bottom-end -> below, right aligned
// x-anchor.left -> left side, vertically centered
// x-anchor.left-start -> left side, top aligned
// x-anchor.left-end -> left side, bottom aligned
// x-anchor.right -> right side, vertically centered
// x-anchor.right-start -> right side, top aligned
// x-anchor.right-end -> right side, bottom aligned
3. Placement Options: 12 Positions and Auto Flip
The x-anchor plugin supports twelve positioning options, specified as modifiers. The naming convention follows Floating UI: top, bottom, left, right for the side, optionally combined with -start and -end for alignment along the axis. top-start means: above the anchor, aligned to the left. bottom-end means: below the anchor, aligned to the right.
The plugin's most important feature is automatic flip behavior: when there is not enough room at the specified position within the viewport, the element automatically switches to the opposite side. A tooltip with x-anchor.top switches to bottom if the anchor element is too close to the top edge of the viewport. This behavior is active by default and requires no configuration. It is also the behavior that is hardest to implement manually and is missing from most hand-rolled solutions.
4. Tooltip Pattern: Hover-Based With Delay
The classic tooltip pattern shows a short explanatory text when the user hovers over an element or focuses it. Good tooltips have a short delay before appearing (so they do not pop up on accidental hover) and disappear immediately on leave. Closing when the user leaves the floating element itself, when they move the mouse onto the tooltip, must also be taken into account.
document.addEventListener('alpine:init', () => {
Alpine.data('tooltip', (content, placement = 'top') => ({
visible: false,
placement,
content,
_timer: null,
show() {
clearTimeout(this._timer);
this._timer = setTimeout(() => { this.visible = true; }, 200);
},
hide() {
clearTimeout(this._timer);
this._timer = setTimeout(() => { this.visible = false; }, 100);
},
stayVisible() {
clearTimeout(this._timer);
},
destroy() {
clearTimeout(this._timer);
}
}));
});
/* HTML usage:
<div
x-data="tooltip('Add product to wishlist')"
class="relative inline-block"
>
<!-- Anchor -->
<button
x-ref="anchor"
@mouseenter="show()"
@mouseleave="hide()"
@focus="show()"
@blur="hide()"
:aria-describedby="visible ? 'tooltip-1' : undefined"
class="..."
>
♥
</button>
<!-- Tooltip -->
<div
x-show="visible"
x-anchor.top="$refs.anchor"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@mouseenter="stayVisible()"
@mouseleave="hide()"
id="tooltip-1"
role="tooltip"
style="position: absolute; z-index: 50;"
class="bg-slate-900 text-white text-xs px-3 py-1.5 rounded-lg whitespace-nowrap shadow-lg"
x-text="content"
></div>
</div>
*/
5. Popover Pattern: Click-Based With Outside Close
Popovers are more complex than tooltips: they open on click instead of hover, contain interactive content, and must close when the user clicks outside them. Alpine.js implements outside closing elegantly through the .outside directive: @click.outside="open = false" on the popover element fires whenever a click happens anywhere outside the element. Combined with x-anchor, the popover is positioned correctly no matter where on the page it is opened.
document.addEventListener('alpine:init', () => {
Alpine.data('popover', () => ({
open: false,
toggle() { this.open = !this.open; },
close() { this.open = false; },
init() {
// Escape key closes the popover
this._keyHandler = (e) => {
if (e.key === 'Escape' && this.open) {
this.open = false;
this.$refs.trigger?.focus(); // Return focus to the trigger
}
};
document.addEventListener('keydown', this._keyHandler);
},
destroy() {
document.removeEventListener('keydown', this._keyHandler);
}
}));
});
/* HTML usage:
<div x-data="popover()" class="relative inline-block">
<!-- Trigger -->
<button
x-ref="trigger"
@click="toggle()"
:aria-expanded="open"
:aria-controls="open ? 'popover-content' : undefined"
class="flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-teal-600"
>
More info
<svg class="w-4 h-4 transition-transform" :class="open ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Popover -->
<div
x-show="open"
x-anchor.bottom-start="$refs.trigger"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.outside="close()"
id="popover-content"
role="dialog"
:aria-label="open ? 'More information' : undefined"
style="position: absolute; z-index: 50; width: 300px;"
class="bg-white border border-slate-200 rounded-2xl shadow-xl p-5"
>
<h3 class="font-semibold text-slate-900 mb-2">Product details</h3>
<p class="text-sm text-slate-600 mb-4">Detailed information about delivery time, materials and care instructions.</p>
<button @click="close()" class="text-xs text-slate-500 hover:text-slate-700">Close</button>
</div>
</div>
*/
6. Dropdown Menu With x-anchor
Dropdown menus are a special kind of popover that hold a list of actions or links. Positioning is critical: the dropdown must open below the trigger button, align to the right (for buttons near the right edge), and not overlap with other page elements. x-anchor.bottom-end is the right option for right-aligned triggers: the dropdown opens below and is right-aligned with the trigger.
Keyboard navigation is especially important for dropdown menus: arrow keys navigate between entries, Enter selects one, Escape closes the menu and returns focus to the trigger. The ARIA attributes role="menu", role="menuitem" and aria-haspopup="menu" make the menu correctly interpretable for screen readers. Alpine.js makes implementing this keyboard logic easier through @keydown handlers on the container.
7. Offset, Arrow and Styling
The x-anchor plugin supports a configurable gap between the anchor and the floating element via the offset modifier: x-anchor.bottom.offset.8 adds an 8px gap. For larger gaps, use x-anchor.bottom.offset.16. The offset value corresponds to Tailwind spacing units: 4 equals 16px, 8 equals 32px. This behavior is consistent with Tailwind but can be confusing if you expect other units.
An arrow (caret) pointing to the anchor element is a common design element for tooltips. The plugin does not include a built-in arrow, this must be implemented manually with CSS. The common pattern is an absolutely positioned pseudo-element or a rotated div placed at the edge of the floating element. The exact position of the arrow depends on the current placement, so for dynamically changing placements (due to flip), Alpine.js can track the current position through a reactive variable that gets updated in the x-anchor callback.
8. Accessibility: ARIA Attributes for Tooltips and Popovers
Tooltips and popovers have different ARIA requirements. A tooltip is a non-interactive label: role="tooltip" on the floating element, aria-describedby="tooltip-id" on the trigger (only while visible). The trigger itself remains the focus point, the tooltip is only shown while the trigger is focused or hovered. Keyboard users must be able to trigger the tooltip by focusing the trigger.
A popover contains interactive content and is therefore its own interaction zone: role="dialog" on the floating element, aria-expanded on the trigger, aria-controls pointing to the popover's ID. On opening, focus must move into the popover ($nextTick(() => $el.querySelector('button, a, input')?.focus())), and on closing it must return to the trigger. The dropdown menu pattern uses role="menu" and role="menuitem", which differs from dialog and has its own keyboard navigation requirements.
9. x-anchor vs. Manual Positioning vs. Tippy.js
The comparison shows where the strengths of x-anchor lie compared to the alternatives.
| Aspect | x-anchor | Manual CSS | Tippy.js |
|---|---|---|---|
| Flip behavior | Automatic | Must implement manually | Automatic |
| Scroll awareness | Built in | Scroll listener required | Built in |
| Alpine integration | Native | CSS is usually enough | Adapter required |
| Bundle size | ~4 KB (with Floating UI) | 0 KB | ~25 KB |
| Transform compatibility | Accounts for transforms | Breaks with transform | Accounts for transforms |
Manual CSS positioning is the right choice when the floating element will always sit in the same relative position and no flip behavior is needed, for example a simple dropdown below a button that always has enough room underneath. x-anchor is the right choice when flip behavior matters or the element is used at different places on the page. Tippy.js offers more configuration options, but also considerably more weight and its own API that is not integrated into the Alpine.js programming model.
10. Summary
The x-anchor plugin from Alpine.js solves the well known problem of tooltip and popover positioning using the Floating UI engine. Through automatic flip behavior, scroll awareness and viewport boundary checks, the plugin produces floating elements that behave correctly in every viewport position and scroll state. Integration into Alpine.js is seamless: a directive on the floating element, a $refs reference to the anchor, and the plugin handles the rest.
In practice, x-anchor is the recommended solution for tooltips, popovers and dropdowns in Hyva projects and other Alpine.js applications, whenever flip behavior or scroll awareness is needed. For simple dropdowns that are always positioned the same way, CSS is enough. Accessibility must be implemented explicitly, the plugin only handles positioning, ARIA attributes and keyboard navigation must be added manually.
Alpine.js x-anchor: The Essentials at a Glance
Register the plugin
Alpine.plugin(anchor) before Alpine.start(). NPM: @alpinejs/anchor. Brings Floating UI along as a dependency.
Use the directive
x-anchor.bottom-start="$refs.trigger" on the floating element. 12 placement options as modifiers. Automatic flip with no configuration.
Accessibility
Tooltip: role="tooltip" plus aria-describedby. Popover: role="dialog" plus focus management. Dropdown: role="menu" plus keyboard navigation.
Offset and styling
x-anchor.bottom.offset.8 for an 8px gap. Implement the arrow/caret manually with CSS. position: absolute; z-index: 50; on the floating element.
Mironsoft
Alpine.js, Hyva themes and Magento 2 frontend development
Accessible UI components for your Hyva project?
We build tooltips, popovers, dropdowns and modals with Alpine.js: accessible, CSP compliant and free of external dependencies. Correct positioning with x-anchor included.
Tooltip systems
Delayed hover tooltips with flip behavior and ARIA for every Hyva component
Dropdown menus
Keyboard navigable dropdown menus positioned correctly with x-anchor
Accessibility
WCAG 2.1 AA compliant interaction components with ARIA and focus management
11. FAQ: Alpine.js x-anchor
1What is x-anchor and why do I need it?
2Install separately?
@alpinejs/anchor via NPM. Alpine.plugin(anchor) before Alpine.start(). Floating UI ships with the plugin.3Set the position?
x-anchor.bottom-start, x-anchor.top, x-anchor.right-end and so on. Twelve combinations possible.4What happens with no room at the viewport edge?
top becomes bottom, right becomes left and so on. Active by default.5Set a gap to the anchor?
x-anchor.bottom.offset.8 for 8px. Corresponds to Tailwind spacing units (4=16px, 8=32px).6Place it in the body?
overflow: hidden containers the element would get clipped. For Hyva, place it directly in the layout template.7Reference the anchor?
$refs: x-anchor.bottom="$refs.trigger" on the floating element, x-ref="trigger" on the anchor. Same x-data scope required.8ARIA for tooltips?
role="tooltip" on the element, aria-describedby="id" on the trigger (only while visible). Tooltips are not interactive.9ARIA for popovers?
role="dialog", aria-expanded plus aria-controls on the trigger. Move focus into the dialog on open, back to the trigger on close. Escape closes it.