Popovers & tooltips
Use this skill to build popovers, tooltips, dropdowns and menus that stay on-screen and
aren't clipped.
Core principle: Tailwind styles, it does NOT position
Tailwind has no positioning logic — it only paints. Don't hand-roll top/left math from
getBoundingClientRect(); you'll reinvent collision detection badly and the popover will open
off-screen. Reach for a positioner that does collision detection (flip + shift) and render
the floating element in the top layer / a portal so an ancestor's overflow, transform,
or z-index can't clip it.
Why popovers go off-screen (the failure modes)
- No flip/shift fallback, so a bottom-anchored tooltip near the viewport edge overflows.
- Clipped by an ancestor with
overflow:hidden, a transform, or its own stacking context.
- Positioned
absolute inside a scrolled container, so it drifts on scroll.
- Never re-positioned on scroll/resize.
The four things that fix it
offset() — gap from the reference.
flip() — pick the side with room.
shift({ padding }) — slide along the axis to stay in the viewport.
arrow() + autoUpdate() — arrow placement, and re-position on scroll/resize/layout.
Plus: render in the top layer (native popover) or portal to <body> to escape
clipping.
Primary — Floating UI + Stimulus (Rails 8 / Hotwire)
Floating UI (@floating-ui/dom, the framework-agnostic successor
to Popper) is the right tool in a Hotwire app: call it from a Stimulus controller. Install with
bin/importmap pin @floating-ui/dom (or pin it in your esbuild/jsbundling setup).
<%# Tailwind here is styling ONLY — positioning is the controller's job %>
<span data-controller="tooltip" data-tooltip-text-value="Archive this card">
<button data-tooltip-target="trigger" class="rounded p-2 hover:bg-gray-100">Archive</button>
</span>
// app/javascript/controllers/tooltip_controller.js
import { Controller } from "@hotwired/stimulus"
import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
static targets = ["trigger"]
static values = { text: String }
connect() {
this.tip = document.createElement("div")
this.tip.textContent = this.textValue
this.tip.role = "tooltip"
// Styling only. Note `w-max` + a `max-w-*` so long text wraps instead of overflowing.
this.tip.className =
"hidden absolute top-0 left-0 w-max max-w-xs rounded bg-gray-900 px-2 py-1 text-sm text-white shadow-lg z-50"
document.body.appendChild(this.tip) // portal out of any clipping ancestor
this.show = () => {
this.tip.classList.remove("hidden")
// autoUpdate keeps it positioned on scroll/resize; returns a cleanup fn.
this.cleanup = autoUpdate(this.triggerTarget, this.tip, () => {
computePosition(this.triggerTarget, this.tip, {
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
Object.assign(this.tip.style, { left: `${x}px`, top: `${y}px` })
})
})
}
this.hide = () => {
this.cleanup?.() // stop the autoUpdate listeners
this.cleanup = null
this.tip.classList.add("hidden")
}
this.triggerTarget.addEventListener("mouseenter", this.show)
this.triggerTarget.addEventListener("focus", this.show)
this.triggerTarget.addEventListener("mouseleave", this.hide)
this.triggerTarget.addEventListener("blur", this.hide)
}
disconnect() {
// TURBO GOTCHA: the tip is portaled to <body>, OUTSIDE this element's subtree, so
// Turbo Drive's cache/restore and Turbo 8 morph won't remove it. Clean up here or
// you leak listeners and orphan stale tooltips across navigations.
this.cleanup?.()
this.tip?.remove()
this.triggerTarget.removeEventListener("mouseenter", this.show)
this.triggerTarget.removeEventListener("focus", this.show)
this.triggerTarget.removeEventListener("mouseleave", this.hide)
this.triggerTarget.removeEventListener("blur", this.hide)
}
}
Alternatives
| Option |
Use when |
| Tippy.js (built on Popper) |
Plain tooltips/popovers — the fastest drop-in; less code than wiring Floating UI yourself. |
| Flowbite / Preline / daisyUI |
You already use that Tailwind component kit — use its popover/dropdown/tooltip rather than hand-rolling. |
Native Popover API (popover attr + popovertarget) |
You want top-layer + light-dismiss for free (Baseline 2024). Pair with CSS anchor positioning for placement — but anchor positioning is Chromium-only as of 2026, so add a Floating UI fallback for cross-browser placement. |
| React: Radix, Headless UI, shadcn/ui |
It's a React app (these wrap Floating UI internally). They do not apply to a Hotwire/Stimulus app. |
Common mistakes
| Mistake |
Fix |
Hand-rolling top/left from getBoundingClientRect() |
Use computePosition with flip() + shift(). |
| Tooltip clipped / hidden |
Portal to <body> (or use the native top layer); don't fight overflow:hidden. |
flip() only, no shift() |
flip() switches sides; shift() slides along the axis — you need both to stay on-screen. |
Forgetting autoUpdate |
Position goes stale on scroll/resize. |
Forgetting disconnect() cleanup under Turbo |
Leaked listeners + orphaned popovers across Turbo visits. Call the autoUpdate cleanup and remove() the portaled node. |
| z-index wars |
Render in the top layer / a high-z portal instead of escalating z-index. |
1---2name: popovers-tooltips3description: Use when building or fixing popovers, tooltips, dropdowns, menus, comboboxes, or any floating/overlay UI — especially when they render off-screen, get clipped, or are mis-positioned. Covers Floating UI in a Stimulus controller (Rails/Hotwire), Tippy/Flowbite/Preline, and the native Popover API.4---56# Popovers & tooltips78Use this skill to build popovers, tooltips, dropdowns and menus that stay **on-screen** and9aren't clipped.1011## Core principle: Tailwind styles, it does NOT position1213Tailwind has no positioning logic — it only paints. Don't hand-roll `top`/`left` math from14`getBoundingClientRect()`; you'll reinvent collision detection badly and the popover will open15off-screen. Reach for a positioner that does **collision detection** (flip + shift) and render16the floating element in the **top layer / a portal** so an ancestor's `overflow`, `transform`,17or `z-index` can't clip it.1819## Why popovers go off-screen (the failure modes)2021- No flip/shift fallback, so a bottom-anchored tooltip near the viewport edge overflows.22- Clipped by an ancestor with `overflow:hidden`, a `transform`, or its own stacking context.23- Positioned `absolute` inside a scrolled container, so it drifts on scroll.24- Never re-positioned on scroll/resize.2526## The four things that fix it27281. `offset()` — gap from the reference.292. `flip()` — pick the side with room.303. `shift({ padding })` — slide along the axis to stay in the viewport.314. `arrow()` + `autoUpdate()` — arrow placement, and re-position on scroll/resize/layout.3233Plus: render in the **top layer** (native `popover`) or **portal to `<body>`** to escape34clipping.3536## Primary — Floating UI + Stimulus (Rails 8 / Hotwire)3738[Floating UI](https://floating-ui.com) (`@floating-ui/dom`, the framework-agnostic successor39to Popper) is the right tool in a Hotwire app: call it from a Stimulus controller. Install with40`bin/importmap pin @floating-ui/dom` (or pin it in your esbuild/jsbundling setup).4142```erb43<%# Tailwind here is styling ONLY — positioning is the controller's job %>44<span data-controller="tooltip" data-tooltip-text-value="Archive this card">45 <button data-tooltip-target="trigger" class="rounded p-2 hover:bg-gray-100">Archive</button>46</span>47```4849```js50// app/javascript/controllers/tooltip_controller.js51import { Controller } from "@hotwired/stimulus"52import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom"5354export default class extends Controller {55 static targets = ["trigger"]56 static values = { text: String }5758 connect() {59 this.tip = document.createElement("div")60 this.tip.textContent = this.textValue61 this.tip.role = "tooltip"62 // Styling only. Note `w-max` + a `max-w-*` so long text wraps instead of overflowing.63 this.tip.className =64 "hidden absolute top-0 left-0 w-max max-w-xs rounded bg-gray-900 px-2 py-1 text-sm text-white shadow-lg z-50"65 document.body.appendChild(this.tip) // portal out of any clipping ancestor6667 this.show = () => {68 this.tip.classList.remove("hidden")69 // autoUpdate keeps it positioned on scroll/resize; returns a cleanup fn.70 this.cleanup = autoUpdate(this.triggerTarget, this.tip, () => {71 computePosition(this.triggerTarget, this.tip, {72 placement: "top",73 middleware: [offset(6), flip(), shift({ padding: 8 })],74 }).then(({ x, y }) => {75 Object.assign(this.tip.style, { left: `${x}px`, top: `${y}px` })76 })77 })78 }79 this.hide = () => {80 this.cleanup?.() // stop the autoUpdate listeners81 this.cleanup = null82 this.tip.classList.add("hidden")83 }8485 this.triggerTarget.addEventListener("mouseenter", this.show)86 this.triggerTarget.addEventListener("focus", this.show)87 this.triggerTarget.addEventListener("mouseleave", this.hide)88 this.triggerTarget.addEventListener("blur", this.hide)89 }9091 disconnect() {92 // TURBO GOTCHA: the tip is portaled to <body>, OUTSIDE this element's subtree, so93 // Turbo Drive's cache/restore and Turbo 8 morph won't remove it. Clean up here or94 // you leak listeners and orphan stale tooltips across navigations.95 this.cleanup?.()96 this.tip?.remove()97 this.triggerTarget.removeEventListener("mouseenter", this.show)98 this.triggerTarget.removeEventListener("focus", this.show)99 this.triggerTarget.removeEventListener("mouseleave", this.hide)100 this.triggerTarget.removeEventListener("blur", this.hide)101 }102}103```104105## Alternatives106107| Option | Use when |108|--------|----------|109| [Tippy.js](https://atomiks.github.io/tippyjs/) (built on Popper) | Plain tooltips/popovers — the fastest drop-in; less code than wiring Floating UI yourself. |110| [Flowbite](https://flowbite.com) / [Preline](https://preline.co) / [daisyUI](https://daisyui.com) | You already use that Tailwind component kit — use its popover/dropdown/tooltip rather than hand-rolling. |111| Native [Popover API](https://developer.mozilla.org/docs/Web/API/Popover_API) (`popover` attr + `popovertarget`) | You want top-layer + light-dismiss for free (Baseline 2024). Pair with CSS anchor positioning for placement — but **anchor positioning is Chromium-only as of 2026**, so add a Floating UI fallback for cross-browser placement. |112| React: [Radix](https://www.radix-ui.com), [Headless UI](https://headlessui.com), [shadcn/ui](https://ui.shadcn.com) | It's a **React** app (these wrap Floating UI internally). They do **not** apply to a Hotwire/Stimulus app. |113114## Common mistakes115116| Mistake | Fix |117|---------|-----|118| Hand-rolling `top`/`left` from `getBoundingClientRect()` | Use `computePosition` with `flip()` + `shift()`. |119| Tooltip clipped / hidden | Portal to `<body>` (or use the native top layer); don't fight `overflow:hidden`. |120| `flip()` only, no `shift()` | `flip()` switches sides; `shift()` slides along the axis — you need both to stay on-screen. |121| Forgetting `autoUpdate` | Position goes stale on scroll/resize. |122| Forgetting `disconnect()` cleanup under Turbo | Leaked listeners + orphaned popovers across Turbo visits. Call the `autoUpdate` cleanup and `remove()` the portaled node. |123| z-index wars | Render in the top layer / a high-z portal instead of escalating `z-index`. |