Popover Rails Components
Display contextual information, tooltips, menus, and interactive content in floating overlays. Built with Floating UI for intelligent positioning and smooth animations.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import { computePosition, offset, flip, shift, autoUpdate, arrow } from "@floating-ui/dom";
export default class extends Controller {
static targets = ["content", "button"];
static values = {
placement: { type: String, default: "top" }, // Placement(s) of the tooltip, e.g., "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end"
offset: { type: Number, default: 10 }, // Offset of the popover
trigger: { type: String, default: "mouseenter focus" }, // Trigger(s) of the popover, e.g., "mouseenter focus", "click", "hover"
interactive: { type: Boolean, default: false }, // Whether the popover is interactive
maxWidth: { type: Number, default: 300 }, // Maximum width of the popover
hasArrow: { type: Boolean, default: true }, // Whether the popover has an arrow
animation: { type: String, default: "fade" }, // Animation type of the popover, e.g., "fade", "origin"
delay: { type: Number, default: 0 }, // Delay before showing the popover
};
_hasAnimationType(type) {
return this.animationValue.split(" ").includes(type);
}
connect() {
this.popoverElement = document.createElement("div");
this.popoverElement.className =
"popover-content shadow-sm absolute text-sm bg-white dark:bg-neutral-800 border border-black/10 dark:border-white/10 rounded-lg opacity-0 z-[1000]";
this.popoverElement.style.maxWidth = `${this.maxWidthValue}px`;
this.popoverElement.style.display = "none";
this.showTimeoutId = null;
this.hideTimeout = null;
if (this._hasAnimationType("fade") || this._hasAnimationType("origin")) {
this.popoverElement.classList.add("transition-all");
}
if (this._hasAnimationType("fade")) {
this.popoverElement.classList.add("duration-250");
}
if (this._hasAnimationType("origin")) {
this.popoverElement.classList.add("duration-250", "ease-out");
this.popoverElement.classList.add("scale-95");
}
if (this.hasContentTarget) {
this.popoverContentHTML = this.contentTarget.innerHTML;
this.popoverElement.innerHTML = this.popoverContentHTML;
// Add event listeners for close buttons within the popover content
this.popoverElement.querySelectorAll("[data-popover-close-button]").forEach((button) => {
button.addEventListener("click", () => this.close());
});
} else {
console.warn(
"Popover content target not found. Please define a <template data-popover-target='content'> element."
);
return;
}
if (this.hasArrowValue) {
// Create arrow container with padding to prevent clipping at viewport edges
this.arrowContainer = document.createElement("div");
this.arrowContainer.className = "absolute z-[999]";
this.arrowElement = document.createElement("div");
this.arrowElement.className = "popover-arrow w-3 h-3 rotate-45 border-[#E5E5E5] dark:border-[#3C3C3C]";
this.arrowContainer.appendChild(this.arrowElement);
this.popoverElement.appendChild(this.arrowContainer);
}
const appendTarget = this.element.closest("dialog[open]") || document.body;
appendTarget.appendChild(this.popoverElement);
this.triggerElement = this.hasButtonTarget ? this.buttonTarget : this.element;
this._showBound = this.show.bind(this);
this._hideBound = this.hide.bind(this);
this._toggleBound = this._toggle.bind(this);
this._scheduleHideBound = this._scheduleHide.bind(this);
this._clearHideTimeoutBound = this._clearHideTimeout.bind(this);
this._handleInteractiveFocusOutBound = this._handleInteractiveFocusOut.bind(this);
this.triggerValue.split(" ").forEach((event_type) => {
if (event_type === "click") {
this.triggerElement.addEventListener("click", this._toggleBound);
} else {
const domEventType = event_type === "focus" ? "focusin" : event_type;
this.triggerElement.addEventListener(domEventType, this._showBound);
let leaveDomEventType = null;
if (event_type === "mouseenter") {
leaveDomEventType = "mouseleave";
} else if (event_type === "focus") {
leaveDomEventType = "focusout";
}
if (leaveDomEventType && !this.interactiveValue) {
this.triggerElement.addEventListener(leaveDomEventType, this._hideBound);
}
}
});
if (this.interactiveValue) {
this.popoverElement.addEventListener("mouseenter", this._clearHideTimeoutBound);
this.popoverElement.addEventListener("mouseleave", this._scheduleHideBound);
this.triggerElement.addEventListener("mouseleave", this._scheduleHideBound);
this.triggerElement.addEventListener("focusout", this._handleInteractiveFocusOutBound);
this.popoverElement.addEventListener("focusout", this._handleInteractiveFocusOutBound);
// Prevent clicks inside the popover from closing it
this._handlePopoverClickBound = this._handlePopoverClick.bind(this);
this.popoverElement.addEventListener("click", this._handlePopoverClickBound);
}
this.cleanupAutoUpdate = null;
this.intersectionObserver = null;
}
disconnect() {
clearTimeout(this.showTimeoutId);
clearTimeout(this.hideTimeout);
this.triggerValue.split(" ").forEach((event_type) => {
if (event_type === "click") {
this.triggerElement.removeEventListener("click", this._toggleBound);
} else {
const domEventType = event_type === "focus" ? "focusin" : event_type;
this.triggerElement.removeEventListener(domEventType, this._showBound);
let leaveDomEventType = null;
if (event_type === "mouseenter") {
leaveDomEventType = "mouseleave";
} else if (event_type === "focus") {
leaveDomEventType = "focusout";
}
if (leaveDomEventType && !this.interactiveValue) {
this.triggerElement.removeEventListener(leaveDomEventType, this._hideBound);
}
}
});
if (this.interactiveValue) {
this.popoverElement.removeEventListener("mouseenter", this._clearHideTimeoutBound);
this.popoverElement.removeEventListener("mouseleave", this._scheduleHideBound);
this.triggerElement.removeEventListener("mouseleave", this._scheduleHideBound);
this.triggerElement.removeEventListener("focusout", this._handleInteractiveFocusOutBound);
this.popoverElement.removeEventListener("focusout", this._handleInteractiveFocusOutBound);
if (this._handlePopoverClickBound) {
this.popoverElement.removeEventListener("click", this._handlePopoverClickBound);
}
}
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate();
this.cleanupAutoUpdate = null;
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
if (this.popoverElement && this.popoverElement.parentElement) {
this.popoverElement.remove();
}
}
get isOpen() {
return this.popoverElement && this.popoverElement.classList.contains("opacity-100");
}
async show() {
clearTimeout(this.showTimeoutId);
this._clearHideTimeout();
if (document.body.classList.contains("dragging")) {
return false;
}
if (!this.popoverElement) return;
this.showTimeoutId = setTimeout(async () => {
this.popoverElement.style.display = "";
const currentAppendTarget = this.element.closest("dialog[open]") || document.body;
if (this.popoverElement.parentElement !== currentAppendTarget) {
currentAppendTarget.appendChild(this.popoverElement);
}
if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();
this.cleanupAutoUpdate = autoUpdate(
this.triggerElement,
this.popoverElement,
async () => {
// Parse placement value to support multiple placements
const placements = this.placementValue.split(/[\s,]+/).filter(Boolean);
const primaryPlacement = placements[0] || "top";
const fallbackPlacements = placements.slice(1);
const middleware = [
offset(this.offsetValue),
flip({
fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : undefined,
}),
shift({ padding: 8 }),
];
if (this.hasArrowValue && this.arrowContainer) {
middleware.push(arrow({ element: this.arrowContainer, padding: 5 }));
}
const { x, y, placement, middlewareData } = await computePosition(this.triggerElement, this.popoverElement, {
placement: primaryPlacement,
middleware: middleware,
});
Object.assign(this.popoverElement.style, {
left: `${x}px`,
top: `${y}px`,
});
if (this._hasAnimationType("origin")) {
const basePlacement = placement.split("-")[0];
this.popoverElement.classList.remove("origin-top", "origin-bottom", "origin-left", "origin-right");
if (basePlacement === "top") {
this.popoverElement.classList.add("origin-bottom");
} else if (basePlacement === "bottom") {
this.popoverElement.classList.add("origin-top");
} else if (basePlacement === "left") {
this.popoverElement.classList.add("origin-right");
} else if (basePlacement === "right") {
this.popoverElement.classList.add("origin-left");
}
}
if (this.hasArrowValue && this.arrowContainer && this.arrowElement && middlewareData.arrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow;
const basePlacement = placement.split("-")[0];
const staticSide = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[basePlacement];
// Apply appropriate padding based on placement direction
this.arrowContainer.classList.remove("px-1", "py-1");
if (basePlacement === "top" || basePlacement === "bottom") {
this.arrowContainer.classList.add("px-1"); // Horizontal padding for top/bottom
} else {
this.arrowContainer.classList.add("py-1"); // Vertical padding for left/right
}
// Position the arrow container
Object.assign(this.arrowContainer.style, {
left: arrowX != null ? `${arrowX}px` : "",
top: arrowY != null ? `${arrowY}px` : "",
right: "",
bottom: "",
[staticSide]: "-0.4rem",
});
// Style the arrow element within the container
this.arrowElement.classList.remove("border-t", "border-r", "border-b", "border-l");
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const arrowColor = isDarkMode ? "rgb(38, 38, 38)" : "white";
let gradientStyle = "";
if (staticSide === "bottom") {
this.arrowElement.classList.add("border-b", "border-r");
gradientStyle = `linear-gradient(to top left, ${arrowColor} 50%, transparent 50.1%)`;
} else if (staticSide === "top") {
this.arrowElement.classList.add("border-t", "border-l");
gradientStyle = `linear-gradient(to bottom right, ${arrowColor} 50%, transparent 50.1%)`;
} else if (staticSide === "left") {
this.arrowElement.classList.add("border-b", "border-l");
gradientStyle = `linear-gradient(to top right, ${arrowColor} 50%, transparent 50.1%)`;
} else if (staticSide === "right") {
this.arrowElement.classList.add("border-t", "border-r");
gradientStyle = `linear-gradient(to bottom left, ${arrowColor} 50%, transparent 50.1%)`;
}
this.arrowElement.style.backgroundImage = gradientStyle;
this.arrowElement.style.backgroundColor = "transparent";
}
},
{ animationFrame: true }
);
// Setup intersection observer to hide popover when trigger element goes out of view
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
this.intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
this.hide();
}
});
},
{ threshold: 0 } // Hide as soon as any part goes out of view
);
this.intersectionObserver.observe(this.triggerElement);
requestAnimationFrame(() => {
let applyOpacity100 = false;
let applyScale100 = false;
if (this._hasAnimationType("fade")) {
applyOpacity100 = true;
}
if (this._hasAnimationType("origin")) {
applyOpacity100 = true;
applyScale100 = true;
}
if (this.animationValue === "none" || (!this._hasAnimationType("fade") && !this._hasAnimationType("origin"))) {
applyOpacity100 = true;
}
if (applyOpacity100) {
this.popoverElement.classList.remove("opacity-0");
this.popoverElement.classList.add("opacity-100");
}
if (applyScale100) {
this.popoverElement.classList.remove("scale-95");
this.popoverElement.classList.add("scale-100");
}
});
}, this.delayValue);
}
hide() {
clearTimeout(this.showTimeoutId);
this._clearHideTimeout();
if (!this.popoverElement) {
return;
}
if (this.cleanupAutoUpdate) {
this.cleanupAutoUpdate();
this.cleanupAutoUpdate = null;
}
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
const hasFade = this._hasAnimationType("fade");
const hasOrigin = this._hasAnimationType("origin");
let applicableHideDelay = 0;
this.popoverElement.classList.remove("opacity-100");
this.popoverElement.classList.add("opacity-0");
if (hasOrigin) {
this.popoverElement.classList.remove("scale-100");
this.popoverElement.classList.add("scale-95");
applicableHideDelay = Math.max(applicableHideDelay, 250);
} else {
this.popoverElement.classList.remove("scale-100", "scale-95");
}
if (hasFade) {
applicableHideDelay = Math.max(applicableHideDelay, 250);
}
if (this.animationValue === "none" || applicableHideDelay === 0) {
this.popoverElement.style.display = "none";
this.popoverElement.classList.remove("opacity-100", "scale-100", "scale-95");
this.popoverElement.classList.add("opacity-0");
return;
}
this.hideTimeout = setTimeout(() => {
if (this.popoverElement) {
this.popoverElement.style.display = "none";
this.popoverElement.classList.remove("opacity-100", "scale-100");
this.popoverElement.classList.add("opacity-0");
if (hasOrigin) {
this.popoverElement.classList.add("scale-95");
} else {
this.popoverElement.classList.remove("scale-95");
}
}
}, applicableHideDelay);
}
_toggle(event) {
event.stopPropagation();
this.popoverElement.classList.contains("opacity-100") ? this.hide() : this.show();
}
_scheduleHide() {
if (!this.interactiveValue && !this.triggerValue.includes("click")) return this.hide();
if (this.triggerValue.includes("click") && !this.interactiveValue) return;
this.hideTimeout = setTimeout(() => this.hide(), 200);
}
_clearHideTimeout() {
if (this.hideTimeout) clearTimeout(this.hideTimeout);
}
_handleInteractiveFocusOut(event) {
if (!this.popoverElement || !this.triggerElement) {
return;
}
// Use a longer timeout to handle checkbox and other form element clicks properly
setTimeout(() => {
if (!this.popoverElement || !this.triggerElement || !document.body.contains(this.triggerElement)) {
return;
}
const activeElement = document.activeElement;
const isFocusInsideTrigger = this.triggerElement.contains(activeElement) || activeElement === this.triggerElement;
const isFocusInsidePopover = this.popoverElement.contains(activeElement);
// Also check if the related target (where focus is going) is inside the popover
const relatedTarget = event.relatedTarget;
const isRelatedTargetInsidePopover = relatedTarget && this.popoverElement.contains(relatedTarget);
const isRelatedTargetInsideTrigger =
relatedTarget && (this.triggerElement.contains(relatedTarget) || relatedTarget === this.triggerElement);
if (
!isFocusInsideTrigger &&
!isFocusInsidePopover &&
!isRelatedTargetInsidePopover &&
!isRelatedTargetInsideTrigger
) {
this._scheduleHide();
} else {
this._clearHideTimeout();
}
}, 50); // Increased timeout to handle form element interactions better
}
_handlePopoverClick(event) {
// Clear any scheduled hide when clicking inside the popover
this._clearHideTimeout();
// Stop event propagation to prevent outside click handlers from firing
event.stopPropagation();
}
// Public method to close the popover from within its content
close() {
this.hide();
}
}
2. Floating UI Installation
The popover component relies on Floating UI for intelligent positioning. Choose your preferred installation method:
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]/+esm"
npm install @floating-ui/dom
yarn add @floating-ui/dom
Examples
Basic popover
A simple popover with header and content, triggered on hover with customizable placement.
Help Information
This is a helpful popover that provides additional context and information when you hover over the help icon.
Popovers are great for providing tooltips, help text, and additional details without cluttering the interface.
About this popover
This popover appears on the right side of the button.
About this popover
This popover appears on the right side of the button.
<!-- Basic Popover Example -->
<div class="flex items-center gap-4">
<div class="inline-block relative" data-controller="popover">
<button type="button" class="cursor-help flex items-center size-[1rem] justify-center rounded-full border border-neutral-200 bg-neutral-50 p-2 text-sm font-semibold leading-5 text-neutral-800 hover:text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:text-neutral-200 focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200">
<span class="text-xs">?</span>
</button>
<template data-popover-target="content">
<p class="border-b border-b-neutral-100 bg-neutral-50 px-3 py-2.5 rounded-t-[0.4375rem] font-semibold dark:border-neutral-700/75 dark:bg-neutral-700/25 text-neutral-700 dark:text-neutral-200">
Help Information
</p>
<div class="p-3 flex flex-col gap-y-3 text-neutral-500 dark:text-neutral-300 font-normal">
<p>
This is a helpful popover that provides additional context and information when you hover over the help icon.
</p>
<p>
Popovers are great for providing tooltips, help text, and additional details without cluttering the interface.
</p>
</div>
</template>
</div>
<div class="inline-block relative" data-controller="popover" data-popover-placement-value="right">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Hover me
</button>
<template data-popover-target="content">
<p class="border-b border-b-neutral-100 bg-neutral-50 px-3 py-2.5 rounded-t-[0.4375rem] font-semibold dark:border-neutral-700/75 dark:bg-neutral-700/25 text-neutral-700 dark:text-neutral-200">
About this popover
</p>
<div class="p-3 flex flex-col gap-y-3 text-neutral-500 dark:text-neutral-300 font-normal">
<p>
This popover appears on the right side of the button.
</p>
</div>
</template>
</div>
<div class="inline-block relative" data-controller="popover" data-popover-has-arrow-value="false">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
No arrow
</button>
<template data-popover-target="content">
<p class="border-b border-b-neutral-100 bg-neutral-50 px-3 py-2.5 rounded-t-[0.4375rem] font-semibold dark:border-neutral-700/75 dark:bg-neutral-700/25 text-neutral-700 dark:text-neutral-200">
About this popover
</p>
<div class="p-3 flex flex-col gap-y-3 text-neutral-500 dark:text-neutral-300 font-normal">
<p>
This popover appears on the right side of the button.
</p>
</div>
</template>
</div>
</div>
Popover positions
Demonstrate all 12 available placement options provided by Floating UI.
Top-start placement
Top placement
Top-end placement
Left-start placement
Right-start placement
Left placement
Right placement
Left-end placement
Right-end placement
Bottom-start placement
Bottom placement
Bottom-end placement
<!-- Popover Positions Example -->
<div class="flex flex-col sm:grid grid-cols-3 gap-4 place-items-center py-8">
<!-- Top row - top positions -->
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="top-start">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
top-start
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Top-start placement</p>
</div>
</template>
</div>
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="top">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
top
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Top placement</p>
</div>
</template>
</div>
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="top-end">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
top-end
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Top-end placement</p>
</div>
</template>
</div>
<!-- Middle row - left positions -->
<div class="w-full col-start-1 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="left-start">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
left-start
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Left-start placement</p>
</div>
</template>
</div>
</div>
<!-- Center - demonstrates the reference point -->
<div class="relative hidden sm:block">
<!-- Empty center space -->
</div>
<!-- Middle row - right positions -->
<div class="w-full col-start-3 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="right-start">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
right-start
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Right-start placement</p>
</div>
</template>
</div>
</div>
<!-- Second middle row -->
<div class="w-full col-start-1 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="left">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
left
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Left placement</p>
</div>
</template>
</div>
</div>
<div class="hidden sm:block"></div>
<div class="w-full col-start-3 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="right">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
right
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Right placement</p>
</div>
</template>
</div>
</div>
<!-- Third middle row -->
<div class="w-full col-start-1 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="left-end">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
left-end
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Left-end placement</p>
</div>
</template>
</div>
</div>
<div class="hidden sm:block"></div>
<div class="w-full col-start-3 flex gap-4">
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="right-end">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
right-end
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Right-end placement</p>
</div>
</template>
</div>
</div>
<!-- Bottom row - bottom positions -->
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="bottom-start">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
bottom-start
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Bottom-start placement</p>
</div>
</template>
</div>
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="bottom">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
bottom
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Bottom placement</p>
</div>
</template>
</div>
<div class="w-full inline-block relative" data-controller="popover" data-popover-placement-value="bottom-end">
<button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
bottom-end
</button>
<template data-popover-target="content">
<div class="p-3 text-sm">
<p class="font-semibold">Bottom-end placement</p>
</div>
</template>
</div>
</div>
Interactive popover
A popover that stays open when hovering over the content, perfect for forms and interactive elements. Note that this example uses the Clipboard component.
<!-- Interactive Popover Example -->
<div class="flex items-center gap-4">
<!-- Share Popover -->
<div class="inline-block relative" data-controller="popover" data-popover-interactive-value="true" data-popover-animation-value="fade origin">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Share
</button>
<template data-popover-target="content">
<p class="text-center border-b border-b-neutral-100 bg-neutral-50 px-3 py-2.5 rounded-t-[0.4375rem] font-semibold dark:border-neutral-700/75 dark:bg-neutral-700/25 text-neutral-700 dark:text-neutral-200">Share Rails Blocks</p>
<div class="p-2">
<div class="flex flex-col gap-3 text-center">
<div class="flex flex-wrap justify-center gap-2">
<a href="https://twitter.com/intent/tweet?url=https%3A%2F%2Frailsblocks.com&text=Check%20out%20Rails%20Blocks" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-transparent p-2 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200" aria-label="Share on Twitter"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="size-4" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M17.6874 3.0625L12.6907 8.77425L8.37045 3.0625H2.11328L9.58961 12.8387L2.50378 20.9375H5.53795L11.0068 14.6886L15.7863 20.9375H21.8885L14.095 10.6342L20.7198 3.0625H17.6874ZM16.6232 19.1225L5.65436 4.78217H7.45745L18.3034 19.1225H16.6232Z"></path></svg></a>
<a href="https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Frailsblocks.com" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-transparent p-2 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200" aria-label="Share on Facebook"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="size-4" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M14 13.5H16.5L17.5 9.5H14V7.5C14 6.47062 14 5.5 16 5.5H17.5V2.1401C17.1743 2.09685 15.943 2 14.6429 2C11.9284 2 10 3.65686 10 6.69971V9.5H7V13.5H10V22H14V13.5Z"></path></svg></a>
<a href="mailto:?subject=Check%20out%20Rails%20Blocks&body=I%20thought%20you%20might%20be%20interested%20in%20this%3A%20https%3A%2F%2Frailsblocks.com" class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-transparent p-2 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200" aria-label="Share via email"><svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M8.88,8.827c.074,.042,.166,.042,.24,0l7.777-4.283c-.314-1.173-1.376-2.044-2.647-2.044H3.75c-1.267,0-2.326,.865-2.643,2.033l7.773,4.293Z"></path><path d="M9.845,10.14c-.264,.146-.554,.219-.844,.219s-.582-.073-.846-.22L1,6.188v6.562c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V6.2l-7.155,3.94Z"></path></g></svg></a>
<button class="inline-flex items-center justify-center gap-1.5 rounded-lg bg-transparent p-2 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200"
data-controller="clipboard"
data-clipboard-text="https://railsblocks.com"
data-clipboard-show-tooltip-value="false">
<%# Note that the show tooltip value is true by default and the success message is "Copied!" by default %>
<div data-clipboard-target="copyContent" class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M12.75,2h-.275c-.123-.846-.845-1.5-1.725-1.5h-3.5c-.879,0-1.602,.654-1.725,1.5h-.275c-1.517,0-2.75,1.233-2.75,2.75V14.25c0,1.517,1.233,2.75,2.75,2.75h7.5c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm-5.75,.25c0-.138,.112-.25,.25-.25h3.5c.138,0,.25,.112,.25,.25v1c0,.138-.112,.25-.25,.25h-3.5c-.138,0-.25-.112-.25-.25v-1Zm4.75,10.25H6.25c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75h5.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Zm0-3H6.25c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75h5.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75Z"></path></g></svg>
</div>
<div data-clipboard-target="copiedContent" class="hidden flex items-center gap-2 text-emerald-500 dark:text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M12.75,2h-.275c-.123-.846-.845-1.5-1.725-1.5h-3.5c-.879,0-1.602,.654-1.725,1.5h-.275c-1.517,0-2.75,1.233-2.75,2.75V14.25c0,1.517,1.233,2.75,2.75,2.75h7.5c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm-5.75,.25c0-.138,.112-.25,.25-.25h3.5c.138,0,.25,.112,.25,.25v1c0,.138-.112,.25-.25,.25h-3.5c-.138,0-.25-.112-.25-.25v-1Zm5.35,5.45l-3.75,5c-.136,.181-.346,.291-.572,.299-.009,0-.019,0-.028,0-.216,0-.422-.093-.564-.256l-1.75-2c-.273-.312-.241-.785,.071-1.058s.785-.242,1.058,.071l1.141,1.303,3.195-4.26c.249-.331,.719-.397,1.05-.15,.331,.249,.398,.719,.15,1.05Z"></path></g></svg>
</div>
</button>
</div>
</div>
</div>
</template>
</div>
<!-- Filters Popover -->
<div class="inline-block relative" data-controller="popover" data-popover-interactive-value="true">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Filters
</button>
<template data-popover-target="content">
<p class="text-center border-b border-b-neutral-100 bg-neutral-50 px-3 py-2.5 rounded-t-[0.4375rem] font-semibold dark:border-neutral-700/75 dark:bg-neutral-700/25 text-neutral-700 dark:text-neutral-200">Filters</p>
<div class="w-42 p-3">
<div class="space-y-3">
<form>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input type="checkbox" id="real-time-filter">
<label for="real-time-filter" class="inline-block">
<span class="font-normal">Real Time</span>
</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="top-channels-filter">
<label for="top-channels-filter" class="inline-block">
<span class="font-normal">Top Channels</span>
</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="last-orders-filter">
<label for="last-orders-filter" class="inline-block">
<span class="font-normal">Last Orders</span>
</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="total-spent-filter">
<label for="total-spent-filter" class="inline-block">
<span class="font-normal">Total Spent</span>
</label>
</div>
</div>
<div role="separator" aria-orientation="horizontal" class="-mx-3 my-3 border-b border-b-neutral-100 dark:border-b-neutral-700/75"></div>
<div class="flex justify-between gap-2">
<button type="reset" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3 py-2 text-xs font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Clear
</button>
<button data-popover-close-button type="button" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-2 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
Apply
</button>
</div>
</form>
</div>
</div>
</template>
</div>
</div>
Click-triggered popover
A popover that opens on click instead of hover.
Welcome to Dashboard
This is your new workspace. Here you'll find all your projects, recent activities, settings, and more.
<!-- Click-triggered Popover Example -->
<div class="flex items-center gap-4">
<!-- Data visualization popover -->
<div class="inline-block relative" data-controller="popover" data-popover-trigger-value="click" data-popover-interactive-value="true">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Data
</button>
<template data-popover-target="content">
<div class="relative max-w-[280px] px-3 py-2 text-sm text-neutral-700 dark:text-neutral-200">
<div class="space-y-2">
<div class="text-xs font-semibold w-full text-center">Tuesday, Aug 13</div>
<div class="flex items-center gap-2 text-xs">
<svg width="8" height="8" fill="currentColor" viewBox="0 0 8 8" xmlns="http://www.w3.org/2000/svg" class="shrink-0 text-indigo-500" aria-hidden="true">
<circle cx="4" cy="4" r="4"></circle>
</svg>
<span class="flex grow gap-2">Sales <span class="ml-auto">$40</span></span>
</div>
<div class="flex items-center gap-2 text-xs">
<svg width="8" height="8" fill="currentColor" viewBox="0 0 8 8" xmlns="http://www.w3.org/2000/svg" class="shrink-0 text-purple-500" aria-hidden="true">
<circle cx="4" cy="4" r="4"></circle>
</svg>
<span class="flex grow gap-2">Revenue <span class="ml-auto">$74</span></span>
</div>
<div class="flex items-center gap-2 text-xs">
<svg width="8" height="8" fill="currentColor" viewBox="0 0 8 8" xmlns="http://www.w3.org/2000/svg" class="shrink-0 text-rose-500" aria-hidden="true">
<circle cx="4" cy="4" r="4"></circle>
</svg>
<span class="flex grow gap-2">Costs <span class="ml-auto">$410</span></span>
</div>
</div>
</div>
</template>
</div>
<!-- Interactive tooltip with steps -->
<div class="inline-block relative" data-controller="popover" data-popover-placement-value="top" data-popover-interactive-value="true" data-popover-trigger-value="click">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Start Tour
</button>
<template data-popover-target="content">
<div class="w-72 max-w-[280px] p-4 py-3">
<div class="space-y-3">
<div class="space-y-1">
<p class="text-[13px] font-medium">Welcome to Dashboard</p>
<p class="opacity-75 text-xs">This is your new workspace. Here you'll find all your projects, recent activities, settings, and more.</p>
</div>
<div class="flex items-center justify-between gap-2">
<span class="opacity-75 text-xs">1/3</span>
<button class="text-xs font-medium hover:underline">Next</button>
</div>
</div>
</div>
</template>
</div>
</div>
Popover animations
Different animation options: fade, origin (scale), combined, or no animation.
<!-- Popover Animations Example -->
<div class="flex flex-col items-center gap-4">
<!-- Fade animation (default) -->
<div class="inline-block relative" data-controller="popover" data-popover-animation-value="fade">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Fade Animation
</button>
<template data-popover-target="content">
<div class="p-3">
This popover uses a fade animation (default).
</div>
</template>
</div>
<!-- Origin animation -->
<div class="inline-block relative" data-controller="popover" data-popover-animation-value="origin">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Origin Animation
</button>
<template data-popover-target="content">
<div class="p-3">
This popover grows from the trigger point.
</div>
</template>
</div>
<!-- Combined fade and origin -->
<div class="inline-block relative" data-controller="popover" data-popover-animation-value="fade origin">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Fade & Origin
</button>
<template data-popover-target="content">
<div class="p-3">
This popover uses both fade and origin animations.
</div>
</template>
</div>
<!-- No animation -->
<div class="inline-block relative" data-controller="popover" data-popover-animation-value="none">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
No Animation
</button>
<template data-popover-target="content">
<div class="p-3">
This popover appears instantly without animation.
</div>
</template>
</div>
</div>
Delayed popover
A popover with a delay before showing, preventing accidental triggers.
<!-- Delayed Popover Example -->
<div class="flex items-center gap-4">
<!-- Short delay (500ms) -->
<div class="inline-block relative" data-controller="popover" data-popover-delay-value="500" data-popover-placement-value="bottom">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Delayed (0.5s)
</button>
<template data-popover-target="content">
<div class="p-3">
This popover appears after a 0.5-second delay.
</div>
</template>
</div>
<!-- Longer delay (1000ms) -->
<div class="inline-block relative" data-controller="popover" data-popover-delay-value="1000" data-popover-placement-value="bottom">
<button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">
Delayed (1s)
</button>
<template data-popover-target="content">
<div class="p-3">
This popover appears after a 1-second delay, preventing accidental triggers during rapid hover movements.
</div>
</template>
</div>
</div>
Configuration
The popover component uses Floating UI for intelligent positioning and provides extensive customization options through a Stimulus controller.
Controller Setup
Basic popover structure with required data attributes:
<div data-controller="popover">
<button type="button">Hover me</button>
<template data-popover-target="content">
<div class="p-3">Popover content goes here</div>
</template>
</div>
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
placement
|
Position of the popover relative to trigger element. Supports all 12 placements |
String
|
"top"
|
offset
|
Distance between popover and trigger element in pixels |
Number
|
10
|
trigger
|
Event(s) that trigger the popover. Options: mouseenter, focus, click, or combinations |
String
|
"mouseenter focus"
|
interactive
|
Whether the popover stays open when hovering over its content |
Boolean
|
false
|
maxWidth
|
Maximum width of the popover in pixels |
Number
|
300
|
hasArrow
|
Whether to show the pointing arrow |
Boolean
|
true
|
animation
|
Animation type: fade, origin, fade origin, or none |
String
|
"fade"
|
delay
|
Delay before showing the popover in milliseconds |
Number
|
0
|
Targets
Target | Description | Required |
---|---|---|
content
|
The template element containing the popover content | Required |
button
|
Optional button element to use as trigger instead of the controller element | Optional |
Animation Types
Type | Description |
---|---|
fade
|
Simple opacity transition for a smooth appearance |
origin
|
Scales from 95% to 100% size, appearing to grow from the trigger point |
fade origin
|
Combines both fade and scale animations for a refined effect |
none
|
Instant appearance without any transition effects |
Accessibility Features
- Keyboard Support: Popovers can be triggered with keyboard focus
- Focus Management: Interactive popovers maintain focus within the popover content
- Screen Reader Friendly: Content is accessible when displayed
- Auto-hide: Popovers automatically hide when trigger element scrolls out of view
Best Practices
- Content Structure: Use semantic HTML within the template for better accessibility
- Interactive Elements: Enable interactive mode when popover contains clickable elements
- Mobile Considerations: Consider using click trigger for touch devices
- Performance: Popovers are created on-demand and cleaned up on disconnect