Popover
Displays content in a floating panel anchored to a trigger element.
Default
A basic popover with bottom placement and center alignment.
Dimensions
Set the dimensions for the layer.
Placement Sides
Popovers can be placed on any side of the trigger: top, bottom, left, or right.
This popover appears above the trigger.
This popover appears below the trigger.
This popover appears to the left.
This popover appears to the right.
Alignment
Control how the popover aligns relative to the trigger: start, center, or end.
Aligned to the start of the trigger.
Centered relative to the trigger.
Aligned to the end of the trigger.
With Close Button
Use PopoverClose to add a button that dismisses the popover.
Settings
Adjust your preferences below.
Default Open
A popover can be rendered in an initially open state.
This popover starts in the open state.
With Form Content
Popovers can contain interactive form elements.
Dimensions
Set the dimensions for the layer.
Custom Trigger
The trigger can be customized with additional CSS classes.
Triggered from a primary-styled button.
Triggered from a destructive-styled button.
Anchored floating content.
$ proa ui add popoverInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Popover recipe — semantic root placement and named slot classes.
use crate::theme::rounded;
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Popover placement options.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum PopoverSide {
Top,
#[default]
Bottom,
Left,
Right,
}
impl PopoverSide {
Popover source
Browse and copy the reviewed source included with this Free component.
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{Popover, PopoverClose, PopoverContent, PopoverTrigger};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Popover renders its trigger inline, then its content as a following block.
impl<
Loader: ::proa_core::DataLoader,
B: WriteBuf,
Trigger: MdRenderSync<Loader, B>,
Content: MdRenderSync<Loader, B>,
> MdRenderSync<Loader, B> for Popover<Trigger, Content>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
self.trigger.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
self.content.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// PopoverTrigger renders as just its label text — the button chrome is meaningless in markdown.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for PopoverTrigger<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
self.children.render_md(cx)?;
cx.md_set_inline(prev);
Ok(())
}
}
/// PopoverContent renders its children as a plain block.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for PopoverContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// PopoverClose is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B> for PopoverClose<C> {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popover_renders_trigger_then_content() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Popover {
trigger: Some(PopoverTrigger {
class: None,
children: Some("Open settings"),
}),
content: Some(PopoverContent {
class: None,
children: Some("Set the dimensions for the layer."),
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"Open settings\n\nSet the dimensions for the layer\\.\n\n"
);
}
/// A block-emitting Popover after mid-line prose must not fuse with it.
#[test]
fn popover_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Popover {
trigger: Some(PopoverTrigger {
class: None,
children: Some("Open settings"),
}),
content: Some(PopoverContent {
class: None,
children: Some("Set the dimensions for the layer."),
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"intro prose\n\nOpen settings\n\nSet the dimensions for the layer\\.\n\n"
);
}
#[test]
fn popover_trigger_renders_label() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
PopoverTrigger {
class: None,
children: Some("Open"),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Open");
}
#[test]
fn popover_close_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
PopoverClose {
class: None,
children: Some("Close"),
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Popover component - displays content in a floating panel
//!
//! Similar to Dialog but positioned relative to a trigger element.
use crate::Anatomy;
mod md;
#[allow(clippy::module_inception)]
mod popover;
mod recipe;
/// Static anatomy contract shared by SSR and the delegated runtime.
pub const POPOVER_ANATOMY: Anatomy =
Anatomy::new("popover", &["root", "trigger", "content", "close"]);
pub use popover::{Popover, PopoverClose, PopoverContent, PopoverTrigger};
pub use recipe::{
popover_recipe, PopoverAlign, PopoverRecipe, PopoverRecipeProps, PopoverSide, PopoverStyle,
POPOVER_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(POPOVER_ANATOMY.scope(), "popover");
assert_eq!(
POPOVER_ANATOMY.parts(),
&["root", "trigger", "content", "close"]
);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Popover component implementations
//!
//! SSR markup for popovers. The TypeScript hydration handles positioning,
//! open/close state, and click-outside detection.
use super::recipe::{PopoverAlign, PopoverRecipeProps, PopoverSide, POPOVER_RECIPE};
use super::POPOVER_ANATOMY;
use crate::OverlayState;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Popover root component - manages the popover state
pub struct Popover<Trigger = (), Content = ()> {
/// Whether the popover starts open
pub default_open: bool,
/// Preferred side for the popover
pub side: PopoverSide,
/// Alignment relative to trigger
pub align: PopoverAlign,
/// The trigger element
pub trigger: Option<Trigger>,
/// The popover content
pub content: Option<Content>,
}
impl Popover<(), ()> {
pub const fn new() -> Self {
Self {
default_open: false,
side: PopoverSide::Bottom,
align: PopoverAlign::Center,
trigger: None,
content: None,
}
}
}
impl<Trigger, Content> Default for Popover<Trigger, Content> {
fn default() -> Self {
Self {
default_open: false,
side: PopoverSide::Bottom,
align: PopoverAlign::Center,
trigger: None,
content: None,
}
}
}
impl<
B: WriteBuf,
Trigger: WebRenderSync<L, B>,
Content: WebRenderSync<L, B>,
L: ::proa_core::DataLoader,
> WebRenderSync<L, B> for Popover<Trigger, Content>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = POPOVER_RECIPE.resolve(PopoverRecipeProps {
side: Some(self.side),
align: Some(self.align),
});
let state = OverlayState::new(self.default_open, false);
let initial_display = if state.hidden() { "none" } else { "block" };
html_sync! {
<div
data-popover-root
data-scope={POPOVER_ANATOMY.scope()}
data-part="root"
data-slot="popover"
data-state={state.data_state()}
data-side={style.side().as_str()}
data-align={style.align().as_str()}
class={style}
style={proa_core::style_ok(proa_macros::text!(
"--proa-overlay-display: {};",
initial_display
))}
>
{self.trigger}
{self.content}
</div>
}
.render(cx)
}
}
/// PopoverTrigger - the element that opens the popover
pub struct PopoverTrigger<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for PopoverTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for PopoverTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-popover-trigger
data-scope={POPOVER_ANATOMY.scope()}
data-part="trigger"
data-slot="popover-trigger"
aria-haspopup="dialog"
class={proa_macros::text!("{} {}", POPOVER_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// PopoverContent - the popover container
pub struct PopoverContent<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for PopoverContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for PopoverContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-popover-content
data-scope={POPOVER_ANATOMY.scope()}
data-part="content"
data-slot="popover-content"
role="dialog"
tabindex="-1"
class={proa_macros::text!("{} {}", POPOVER_RECIPE.content, self.class)}
style="display: var(--proa-overlay-display, none); position: absolute;"
>
{self.children}
</div>
}
.render(cx)
}
}
/// PopoverClose - button that closes the popover
pub struct PopoverClose<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for PopoverClose<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for PopoverClose<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-popover-close
data-scope={POPOVER_ANATOMY.scope()}
data-part="close"
data-slot="popover-close"
class={proa_macros::text!("{} {}", POPOVER_RECIPE.close, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn popover_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Popover {
default_open: false,
side: PopoverSide::Bottom,
align: PopoverAlign::Center,
trigger: Some(text("Open")),
content: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-popover-root"));
assert!(html.contains("data-slot=\"popover\""));
assert!(html.contains("data-scope=\"popover\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("data-side=\"bottom\""));
assert!(html.contains("relative"));
assert!(html.contains("inline-block"));
}
#[test]
fn popover_content_renders_hidden() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
PopoverContent {
class: None,
children: Some(text("Hello")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-popover-content"));
assert!(html.contains("data-slot=\"popover-content\""));
assert!(html.contains("data-part=\"content\""));
assert!(html.contains("var(--proa-overlay-display, none)"));
assert!(html.contains("hidden"));
assert!(html.contains("absolute"));
}
#[test]
fn popover_parts_use_named_slots_and_custom_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
PopoverTrigger {
class: Some("trigger-extra"),
children: Some("Open"),
}
.render(&mut cx)
.unwrap();
PopoverClose {
class: Some("close-extra"),
children: Some("Close"),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"popover-trigger\""));
assert!(html.contains("data-part=\"trigger\""));
assert!(html.contains("trigger-extra"));
assert!(html.contains("data-slot=\"popover-close\""));
assert!(html.contains("data-part=\"close\""));
assert!(html.contains(r#"type="button""#));
assert!(html.contains("close-extra"));
}
#[test]
fn runtime_initializes_root_owned_state_and_canonical_ids() {
let js = include_str!("../../../runtime/proa-free.js");
assert!(js.contains("data-popover-id"));
assert!(js.contains("`${id}--trigger`"));
assert!(js.contains("`${id}--content`"));
assert!(js.contains("initializeOnReady(() => Popover.init())"));
assert!(js.contains("content.setAttribute('aria-hidden'"));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Popover recipe — semantic root placement and named slot classes.
use crate::theme::rounded;
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Popover placement options.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum PopoverSide {
Top,
#[default]
Bottom,
Left,
Right,
}
impl PopoverSide {
pub const fn classes(self) -> &'static str {
""
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Top => "top",
Self::Bottom => "bottom",
Self::Left => "left",
Self::Right => "right",
}
}
}
/// Popover alignment relative to its trigger.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "align")]
pub enum PopoverAlign {
#[default]
Center,
Start,
End,
}
impl PopoverAlign {
pub const fn classes(self) -> &'static str {
""
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Center => "center",
Self::Start => "start",
Self::End => "end",
}
}
}
/// Optional root inputs accepted by [`PopoverRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PopoverRecipeProps {
pub side: Option<PopoverSide>,
pub align: Option<PopoverAlign>,
}
/// Static named classes for every Popover part.
#[derive(Debug, Clone, Copy)]
pub struct PopoverRecipe {
pub root: &'static str,
pub trigger: &'static str,
pub content: &'static str,
pub close: &'static str,
side_default: PopoverSide,
align_default: PopoverAlign,
}
impl PopoverRecipe {
pub const fn resolve(&'static self, props: PopoverRecipeProps) -> PopoverStyle {
let side = match props.side {
Some(side) => side,
None => self.side_default,
};
let align = match props.align {
Some(align) => align,
None => self.align_default,
};
PopoverStyle {
recipe: self,
side,
align,
}
}
}
/// Effective root placement and streamable root classes.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct PopoverStyle {
recipe: &'static PopoverRecipe,
side: PopoverSide,
align: PopoverAlign,
}
impl PopoverStyle {
pub const fn side(self) -> PopoverSide {
self.side
}
pub const fn align(self) -> PopoverAlign {
self.align
}
}
impl AttrValue for PopoverStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
const POPOVER_ROOT: &str = tw_join!("relative", "inline-block");
const POPOVER_TRIGGER: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"rounded-md",
"border",
"border-input",
"bg-background",
"h-9",
"px-4",
"py-2",
"text-sm",
"font-medium",
"text-foreground",
"shadow-xs",
"transition-all",
"cursor-pointer",
"hover:bg-accent",
"hover:text-accent-foreground"
);
const POPOVER_CONTENT: &str = tw_join!(
"hidden",
"absolute",
"z-50",
"w-72",
"border",
"border-border",
"bg-popover",
"text-popover-foreground",
"p-4",
"shadow-md",
"outline-hidden",
rounded::MD
);
/// Close has no intrinsic visual treatment; it remains a named slot so
/// authors can target it consistently without the recipe owning markup.
const POPOVER_CLOSE: &str = "";
pub const POPOVER_RECIPE: PopoverRecipe = PopoverRecipe {
root: POPOVER_ROOT,
trigger: POPOVER_TRIGGER,
content: POPOVER_CONTENT,
close: POPOVER_CLOSE,
side_default: PopoverSide::Bottom,
align_default: PopoverAlign::Center,
};
pub const fn popover_recipe() -> &'static PopoverRecipe {
&POPOVER_RECIPE
}
impl crate::variant_spec::ComponentSpec for PopoverRecipe {
const NAME: &'static str = "Popover";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<PopoverSide>(),
crate::variant_spec::variant_metadata::<PopoverAlign>(),
]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::POPOVER_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = popover_recipe();
assert!(recipe.root.contains("relative"));
assert!(recipe.trigger.contains("border-input"));
assert!(recipe.content.contains("bg-popover"));
assert_eq!(recipe.close, "");
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = popover_recipe().resolve(PopoverRecipeProps::default());
assert_eq!(default.side(), PopoverSide::Bottom);
assert_eq!(default.align(), PopoverAlign::Center);
let custom = popover_recipe().resolve(PopoverRecipeProps {
side: Some(PopoverSide::Left),
align: Some(PopoverAlign::End),
});
assert_eq!(custom.side(), PopoverSide::Left);
assert_eq!(custom.align(), PopoverAlign::End);
}
#[test]
fn component_metadata_uses_canonical_anatomy_and_axes() {
use crate::variant_spec::ComponentSpec;
assert_eq!(
POPOVER_RECIPE.anatomy(),
Some(super::super::POPOVER_ANATOMY)
);
assert_eq!(POPOVER_RECIPE.variant_metadata().len(), 2);
}
}