Tooltip
Displays informative text on hover or keyboard focus. Unlike Popover, tooltips are non-interactive and meant for brief content.
Default
A basic tooltip with default settings (top side, center alignment, normal delay).
Sides
Tooltips can be placed on any side of the trigger element.
Alignment
Control the alignment of the tooltip relative to its trigger.
Delay
Control the delay before the tooltip appears. Available presets: None (0ms), Short (100ms), Normal (200ms), and Long (400ms).
With Icons
Tooltips work well as labels for icon-only buttons.
Rich Content
Tooltip content can include HTML for richer formatting.
Keyboard Navigation
Use Tab to move focus between interactive elements.
Accessibility
Tooltips are keyboard accessible via focus. The trigger element receives tabindex="0" and the content has role="tooltip" for screen reader support.
Short contextual help.
$ proa ui add tooltipInstalls 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
//! Tooltip 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};
/// Tooltip placement options.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum TooltipSide {
#[default]
Top,
Bottom,
Left,
Right,
}
impl TooltipSide {
Tooltip 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::{Tooltip, TooltipContent, TooltipTrigger};
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Tooltip renders only its trigger content inline — the hover text is dropped.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, Trigger: MdRenderSync<Loader, B>, Content>
MdRenderSync<Loader, B> for Tooltip<Trigger, Content>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
self.trigger.render_md(cx)?;
cx.md_set_inline(prev);
Ok(())
}
}
/// TooltipTrigger 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 TooltipTrigger<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(())
}
}
/// TooltipContent is hover-only supplementary text — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B>
for TooltipContent<C>
{
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tooltip_renders_trigger_and_drops_content() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Tooltip {
trigger: Some(TooltipTrigger {
class: None,
children: Some("Hover me"),
}),
content: Some(TooltipContent {
class: None,
children: Some("Add to library"),
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Hover me");
}
#[test]
fn tooltip_content_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
TooltipContent {
class: None,
children: Some("Tooltip text"),
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tooltip component - displays informative text on hover/focus
//!
//! Unlike Popover, Tooltip is triggered by hover and keyboard focus,
//! not by click. It's meant for brief, non-interactive content.
use crate::Anatomy;
mod md;
mod recipe;
#[allow(clippy::module_inception)]
mod tooltip;
/// Static anatomy contract shared by SSR and the delegated runtime.
pub const TOOLTIP_ANATOMY: Anatomy = Anatomy::new("tooltip", &["root", "trigger", "content"]);
pub use recipe::{
tooltip_recipe, TooltipAlign, TooltipRecipe, TooltipRecipeProps, TooltipSide, TooltipStyle,
TOOLTIP_RECIPE,
};
pub use tooltip::{Tooltip, TooltipContent, TooltipDelay, TooltipTrigger};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(TOOLTIP_ANATOMY.scope(), "tooltip");
assert_eq!(TOOLTIP_ANATOMY.parts(), &["root", "trigger", "content"]);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tooltip 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};
/// Tooltip placement options.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "side")]
pub enum TooltipSide {
#[default]
Top,
Bottom,
Left,
Right,
}
impl TooltipSide {
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",
}
}
}
/// Tooltip alignment relative to its trigger.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "align")]
pub enum TooltipAlign {
#[default]
Center,
Start,
End,
}
impl TooltipAlign {
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",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TooltipRecipeProps {
pub side: Option<TooltipSide>,
pub align: Option<TooltipAlign>,
}
/// Static named classes for every Tooltip part.
#[derive(Debug, Clone, Copy)]
pub struct TooltipRecipe {
pub root: &'static str,
pub trigger: &'static str,
pub content: &'static str,
side_default: TooltipSide,
align_default: TooltipAlign,
}
impl TooltipRecipe {
pub const fn resolve(&'static self, props: TooltipRecipeProps) -> TooltipStyle {
let side = match props.side {
Some(side) => side,
None => self.side_default,
};
let align = match props.align {
Some(align) => align,
None => self.align_default,
};
TooltipStyle {
recipe: self,
side,
align,
}
}
}
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct TooltipStyle {
recipe: &'static TooltipRecipe,
side: TooltipSide,
align: TooltipAlign,
}
impl TooltipStyle {
pub const fn side(self) -> TooltipSide {
self.side
}
pub const fn align(self) -> TooltipAlign {
self.align
}
}
impl AttrValue for TooltipStyle {
#[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 TOOLTIP_ROOT: &str = tw_join!("relative", "inline-block");
/// Trigger has no intrinsic visual treatment; its native button behavior is
/// preserved while the named slot remains available for additive classes.
const TOOLTIP_TRIGGER: &str = "";
/// `w-max` instead of `whitespace-nowrap`: the bubble is absolutely
/// positioned inside the trigger-sized root, so without an explicit sizing
/// basis it would shrink-wrap to the trigger's width. `max-content` keeps it
/// on one line when it fits, while the runtime caps `max-width` to the
/// viewport so long text wraps instead of overflowing the window.
pub const TOOLTIP_CONTENT_BASE: &str = tw_join!(
"z-50",
"overflow-hidden",
"bg-gray-900",
"text-gray-50",
"px-3",
"py-1.5",
"text-xs",
"shadow-md",
"w-max",
"break-words",
rounded::MD
);
pub const TOOLTIP_RECIPE: TooltipRecipe = TooltipRecipe {
root: TOOLTIP_ROOT,
trigger: TOOLTIP_TRIGGER,
content: TOOLTIP_CONTENT_BASE,
side_default: TooltipSide::Top,
align_default: TooltipAlign::Center,
};
pub const fn tooltip_recipe() -> &'static TooltipRecipe {
&TOOLTIP_RECIPE
}
impl crate::variant_spec::ComponentSpec for TooltipRecipe {
const NAME: &'static str = "Tooltip";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![
crate::variant_spec::variant_metadata::<TooltipSide>(),
crate::variant_spec::variant_metadata::<TooltipAlign>(),
]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::TOOLTIP_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_named_slots() {
let recipe = tooltip_recipe();
assert!(recipe.root.contains("relative"));
assert_eq!(recipe.trigger, "");
assert!(recipe.content.contains("bg-gray-900"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = tooltip_recipe().resolve(TooltipRecipeProps::default());
assert_eq!(default.side(), TooltipSide::Top);
assert_eq!(default.align(), TooltipAlign::Center);
let custom = tooltip_recipe().resolve(TooltipRecipeProps {
side: Some(TooltipSide::Right),
align: Some(TooltipAlign::Start),
});
assert_eq!(custom.side(), TooltipSide::Right);
assert_eq!(custom.align(), TooltipAlign::Start);
}
#[test]
fn component_metadata_uses_canonical_anatomy_and_axes() {
use crate::variant_spec::ComponentSpec;
assert_eq!(
TOOLTIP_RECIPE.anatomy(),
Some(super::super::TOOLTIP_ANATOMY)
);
assert_eq!(TOOLTIP_RECIPE.variant_metadata().len(), 2);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Tooltip component implementations
//!
//! SSR markup for tooltips. The JavaScript hydration handles hover/focus
//! detection, positioning, and show/hide animations.
use super::recipe::{TooltipAlign, TooltipRecipeProps, TooltipSide, TOOLTIP_RECIPE};
use super::TOOLTIP_ANATOMY;
use crate::DisclosureState;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Delay preset for tooltip show delay
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TooltipDelay {
/// No delay (0ms)
None,
/// Short delay (100ms)
Short,
/// Normal delay (200ms) - default
#[default]
Normal,
/// Long delay (400ms)
Long,
}
impl TooltipDelay {
pub const fn as_str(self) -> &'static str {
match self {
TooltipDelay::None => "0",
TooltipDelay::Short => "100",
TooltipDelay::Normal => "200",
TooltipDelay::Long => "400",
}
}
}
/// Tooltip root component - manages the tooltip state
pub struct Tooltip<Trigger = (), Content = ()> {
/// Preferred side for the tooltip
pub side: TooltipSide,
/// Alignment relative to trigger
pub align: TooltipAlign,
/// Delay before showing - used as data attribute for JS
pub delay: TooltipDelay,
/// The trigger element
pub trigger: Option<Trigger>,
/// The tooltip content
pub content: Option<Content>,
}
impl Tooltip<(), ()> {
pub const fn new() -> Self {
Self {
side: TooltipSide::Top,
align: TooltipAlign::Center,
delay: TooltipDelay::Normal,
trigger: None,
content: None,
}
}
}
impl<Trigger, Content> Default for Tooltip<Trigger, Content> {
fn default() -> Self {
Self {
side: TooltipSide::Top,
align: TooltipAlign::Center,
delay: TooltipDelay::Normal,
trigger: None,
content: None,
}
}
}
impl<
B: WriteBuf,
Trigger: WebRenderSync<L, B>,
Content: WebRenderSync<L, B>,
L: ::proa_core::DataLoader,
> WebRenderSync<L, B> for Tooltip<Trigger, Content>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = TOOLTIP_RECIPE.resolve(TooltipRecipeProps {
side: Some(self.side),
align: Some(self.align),
});
let state = DisclosureState::Closed;
html_sync! {
<div
data-tooltip-root
data-scope={TOOLTIP_ANATOMY.scope()}
data-part="root"
data-slot="tooltip"
data-state={state}
data-side={style.side().as_str()}
data-align={style.align().as_str()}
data-delay={self.delay.as_str()}
class={style}
style="--proa-overlay-display: none;"
>
{self.trigger}
{self.content}
</div>
}
.render(cx)
}
}
/// TooltipTrigger - the element that shows the tooltip on hover/focus
pub struct TooltipTrigger<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for TooltipTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TooltipTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-tooltip-trigger
data-scope={TOOLTIP_ANATOMY.scope()}
data-part="trigger"
data-slot="tooltip-trigger"
class={proa_macros::text!("{} {}", TOOLTIP_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// TooltipContent - the tooltip container (typically just text)
pub struct TooltipContent<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for TooltipContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TooltipContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-tooltip-content
data-scope={TOOLTIP_ANATOMY.scope()}
data-part="content"
data-slot="tooltip-content"
role="tooltip"
class={proa_macros::text!("{} {}", TOOLTIP_RECIPE.content, self.class)}
style="display: var(--proa-overlay-display, none); position: absolute;"
>
{self.children}
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn tooltip_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Tooltip {
side: TooltipSide::Top,
align: TooltipAlign::Center,
delay: TooltipDelay::Normal,
trigger: Some(text("Hover me")),
content: Some(text("Tooltip text")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tooltip-root"));
assert!(html.contains("data-slot=\"tooltip\""));
assert!(html.contains("data-scope=\"tooltip\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("data-side=\"top\""));
assert!(html.contains("data-delay=\"200\""));
}
#[test]
fn tooltip_content_renders_hidden() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TooltipContent {
class: None,
children: Some(text("Hello")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tooltip-content"));
assert!(html.contains("data-slot=\"tooltip-content\""));
assert!(html.contains("var(--proa-overlay-display, none)"));
assert!(html.contains("data-part=\"content\""));
assert!(html.contains("role=\"tooltip\""));
}
#[test]
fn tooltip_trigger_is_focusable() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TooltipTrigger {
class: None,
children: Some(text("Trigger")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-tooltip-trigger"));
assert!(html.contains("data-slot=\"tooltip-trigger\""));
assert!(html.contains("data-part=\"trigger\""));
// The trigger uses a `<button>` element which is keyboard-focusable
// by default — no explicit `tabindex` is required. Asserting `<button`
// is both more accurate to the implementation and a stronger
// focusability guarantee (non-button elements with `tabindex="0"`
// would still fail this check).
assert!(html.contains("<button"));
assert!(html.contains(r#"type="button""#));
}
#[test]
fn runtime_wires_describedby_and_reinitializes_after_navigation() {
let js = include_str!("../../../runtime/proa-free.js");
assert!(js.contains("data-tooltip-id"));
assert!(js.contains("`${id}--trigger`"));
assert!(js.contains("`${id}--content`"));
assert!(js.contains("trigger.setAttribute('aria-describedby', content.id)"));
assert!(js.contains("initializeOnReady(() => Tooltip.init())"));
}
}