Components
Popover
Native HTML Popover API. Trigger with popovertarget; the browser handles light dismiss, ESC close, top-layer rendering, focus restoration.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/popover.json2. Use it
import { Popover, PopoverTrigger } from "@/components/ui/popover"
<PopoverTrigger popoverTarget="my-popover" class="…btn classes…">Open</PopoverTrigger>
<Popover id="my-popover">
<p>Body content.</p>
</Popover>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Popover — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Uses the native HTML Popover API (popover + popovertarget attributes).
// The platform gives us:
// - Top-layer rendering (no z-index race with siblings).
// - Light dismiss in "auto" mode (click outside closes it).
// - ESC closes the popover.
// - aria-haspopup / aria-expanded auto-managed on the trigger.
// - Focus restoration to the opener.
//
// shadcn upstream uses Radix Popover; we use the native equivalent.
//
// Refs:
// repos/mdn/files/en-us/web/api/popover_api/ (overview)
// repos/mdn/files/en-us/web/html/global_attributes/popover.md
// repos/mdn/files/en-us/web/html/reference/attributes/popovertarget.md
export type PopoverSide = "top" | "right" | "bottom" | "left"
// Positioning is JS-driven in public/site.js (reads data-side and writes
// inline top/left on `toggle`). CSS Anchor Positioning would replace
// this, but it's Chrome-only at time of writing.
const base =
"z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none " +
// Native [popover] is display:none by default and only revealed when
// open. We add :popover-open animation via Tailwind.
"[&:not(:popover-open)]:hidden " +
// animate-fade-in is keyframed in input.css.
"[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]"
export function popoverClasses(opts?: { class?: ClassValue }): string {
return cn(base, opts?.class)
}
type PopoverProps = PropsWithChildren<{
// Required — used by the trigger via popovertarget.
id: string
// "auto" (default): light dismiss + ESC. "manual": only code can toggle.
// "hint": light-dismissable but does NOT close an open `auto` popover —
// for tooltip/teaching-UI that should coexist with an open menu. Falls
// back to manual in non-supporting browsers (progressive enhancement).
// repos/mdn/files/en-us/web/html/reference/global_attributes/popover/index.md:22-24
mode?: "auto" | "hint" | "manual"
// Side hint — used for anchor positioning if the browser supports it.
side?: PopoverSide
class?: ClassValue
// The native popover attribute assigns NO role and NO accessible name to
// the popover element itself — only an implicit aria relationship on the
// invoker. Supply these for menu/listbox/labelled-dialog popovers.
// repos/mdn/files/en-us/web/api/popover_api/using/index.md:79-86
role?: string
ariaLabelledby?: string
ariaLabel?: string
}>
export function Popover(props: PopoverProps) {
const {
id,
mode = "auto",
side = "bottom",
class: className,
role,
ariaLabelledby,
ariaLabel,
children,
} = props
return (
<div
id={id}
// Native popover attribute. `popover=""` is equivalent to popover="auto".
// Cast: "hint" is a valid platform keyword the Hono JSX types don't list yet.
popover={
(mode === "manual" ? "manual" : mode === "hint" ? "hint" : "auto") as "auto" | "manual"
}
data-slot="popover"
data-side={side}
// role / accessible name emitted only when provided.
role={role}
aria-labelledby={ariaLabelledby}
aria-label={ariaLabel}
class={cn(popoverClasses(), className)}
>
{children}
</div>
)
}
type PopoverTriggerProps = PropsWithChildren<{
// ID of the popover this triggers.
popoverTarget: string
// What clicking the trigger does. Default "toggle".
popoverTargetAction?: "show" | "hide" | "toggle"
class?: ClassValue
id?: string
}>
export function PopoverTrigger(props: PopoverTriggerProps) {
const {
popoverTarget,
popoverTargetAction = "toggle",
children,
class: className,
id,
} = props
// Renders a native <button> carrying the popovertarget attributes. For
// richer chrome, spread { popovertarget, popovertargetaction } onto a
// styled <Button> directly instead of using this trigger.
return (
<button
id={id}
type="button"
popovertarget={popoverTarget}
popovertargetaction={popoverTargetAction}
data-slot="popover-trigger"
class={cn(className)}
>
{children}
</button>
)
}
1. Save the file
Copy popover.html into templates/components/.
2. Use it
{% from "components/popover.html" import popover_trigger, popover_open, popover_close %}
{{ popover_trigger("Open", popover_target="my-popover", class_="…btn…") }}
{% call popover_open(id="my-popover") %}
<p>Body content.</p>
{% endcall %}View source
{# Popover macros — shadcn-htmx, htmx v4 + Tailwind v4.
Native HTML Popover API (popover + popovertarget). Browser provides
light dismiss, ESC close, top-layer rendering, focus restoration.
Usage:
{% from "components/popover.html" import popover_trigger, popover_open, popover_close %}
{{ popover_trigger("Open", popover_target="my-popover", class_="…btn…") }}
{% call popover_open(id="my-popover") %}
<p>Popover body — anything not interactive, like a form, can live here.</p>
{% endcall %} #}
{% macro popover_trigger(label, popover_target, action="toggle", class_="", id=none) %}
<button {% if id %}id="{{ id }}"{% endif %}
type="button"
popovertarget="{{ popover_target }}"
popovertargetaction="{{ action }}"
data-slot="popover-trigger"
class="{{ class_ }}">{{ label }}</button>
{% endmacro %}
{# mode: "auto" (default) | "hint" | "manual".
hint = light-dismissable but does NOT close an open auto popover (falls
back to manual in non-supporting browsers).
repos/mdn/files/en-us/web/html/reference/global_attributes/popover/index.md:22-24
role / aria_labelledby / aria_label: the native popover attribute assigns
no role or accessible name to the popover itself — supply for menu/listbox
popovers. repos/mdn/files/en-us/web/api/popover_api/using/index.md:79-86 #}
{% macro popover_open(id, mode="auto", side="bottom", extra_class="", role=none, aria_labelledby=none, aria_label=none) %}
{%- set sides = {
"top": "anchor-popover-top",
"bottom": "anchor-popover-bottom",
"left": "anchor-popover-left",
"right": "anchor-popover-right"
} -%}
<div id="{{ id }}"
popover="{{ 'manual' if mode == 'manual' else 'hint' if mode == 'hint' else 'auto' }}"
data-slot="popover" data-side="{{ side }}"
{% if role %}role="{{ role }}"{% endif %}
{% if aria_labelledby %}aria-labelledby="{{ aria_labelledby }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
class="z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] {{ sides[side] }} {{ extra_class }}">
{% endmacro %}
{% macro popover_close() %}</div>{% endmacro %}
1. Save the file
Add popover.tmpl alongside button.tmpl.
2. Use it
{{template "popover_trigger" (dict "Label" "Open" "PopoverTarget" "my-popover" "Class" "…btn…")}}
{{template "popover" (dict "ID" "my-popover" "Body" (htmlSafe `<p>Body content.</p>`))}}View source
{{/*
Popover templates — shadcn-htmx, htmx v4 + Tailwind v4.
Native HTML Popover API.
type PopoverArgs struct {
// Mode: "auto" (default) | "hint" | "manual". hint = light-dismissable
// but does NOT close an open auto popover (falls back to manual in
// non-supporting browsers).
// mdn .../global_attributes/popover/index.md:22-24
ID, Mode, Side string
// Role / AriaLabelledby / AriaLabel: the native popover attribute
// assigns no role or accessible name to the popover itself — supply
// for menu/listbox popovers.
// mdn .../api/popover_api/using/index.md:79-86
Role, AriaLabelledby, AriaLabel string
Body template.HTML
}
type PopoverTriggerArgs struct {
Label, PopoverTarget, Action, Class, ID string
}
*/}}
{{define "popover"}}
{{- $mode := or .Mode "auto" -}}
{{- $side := or .Side "bottom" -}}
{{- $sides := dict "top" "anchor-popover-top" "bottom" "anchor-popover-bottom" "left" "anchor-popover-left" "right" "anchor-popover-right" -}}
<div id="{{.ID}}" popover="{{$mode}}" data-slot="popover" data-side="{{$side}}"
{{if .Role}}role="{{.Role}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
class="z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] {{index $sides $side}}">
{{.Body}}
</div>
{{end}}
{{define "popover_trigger"}}
<button {{if .ID}}id="{{.ID}}"{{end}} type="button"
popovertarget="{{.PopoverTarget}}"
popovertargetaction="{{or .Action "toggle"}}"
data-slot="popover-trigger"
class="{{.Class}}">{{.Label}}</button>
{{end}}
1. Save the file
Drop popover.ex into lib/my_app_web/components/.
2. Use it
<.popover_trigger popover_target="my-popover" class="…btn…">Open</.popover_trigger>
<.popover id="my-popover">
<p>Body content.</p>
</.popover>View source
defmodule ShadcnHtmx.Components.Popover do
@moduledoc """
Popover — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Native HTML Popover API. The browser handles light dismiss, ESC close,
top-layer rendering, focus restoration.
## Examples
<.popover_trigger popover_target="my-popover" class="…btn…">
Open
</.popover_trigger>
<.popover id="my-popover">
<p>Body content.</p>
</.popover>
"""
use Phoenix.Component
@sides %{
"top" => "anchor-popover-top",
"bottom" => "anchor-popover-bottom",
"left" => "anchor-popover-left",
"right" => "anchor-popover-right"
}
attr :id, :string, required: true
# "hint" = light-dismissable but does NOT close an open auto popover (falls
# back to manual in non-supporting browsers).
# mdn .../global_attributes/popover/index.md:22-24
attr :mode, :string, default: "auto", values: ~w(auto hint manual)
attr :side, :string, default: "bottom", values: ~w(top right bottom left)
attr :class, :string, default: nil
# The native popover attribute assigns no role or accessible name to the
# popover element itself — supply these for menu/listbox popovers.
# mdn .../api/popover_api/using/index.md:79-86
attr :role, :string, default: nil
attr :aria_labelledby, :string, default: nil
attr :aria_label, :string, default: nil
slot :inner_block, required: true
def popover(assigns) do
assigns = assign(assigns, :side_class, Map.fetch!(@sides, assigns.side))
~H"""
<div
id={@id}
popover={@mode}
data-slot="popover"
data-side={@side}
role={@role}
aria-labelledby={@aria_labelledby}
aria-label={@aria_label}
class={[
"z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"[&:not(:popover-open)]:hidden",
"[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]",
@side_class,
@class
]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :popover_target, :string, required: true
attr :action, :string, default: "toggle", values: ~w(show hide toggle)
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def popover_trigger(assigns) do
~H"""
<button
type="button"
popovertarget={@popover_target}
popovertargetaction={@action}
data-slot="popover-trigger"
class={@class}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
end
1. Save the file
Pure HTML — no JS required.
2. Use it
<button popovertarget="my-popover" popovertargetaction="toggle" class="…">Open</button>
<div id="my-popover" popover class="z-50 m-0 w-72 rounded-md border …">
Body content.
</div>View source
<!--
shadcn-htmx — raw HTML popover snippet.
Uses the native HTML Popover API — no JS required. The platform handles
light dismiss (click outside), ESC close, top-layer rendering, and
focus restoration when closed.
The popover attribute takes three states:
popover / popover="auto" — light dismiss + ESC (shown below).
popover="hint" — light-dismissable but does NOT close an open
auto popover; for tooltip/teaching UI. Falls
back to manual in non-supporting browsers.
popover="manual" — only code can show/hide it.
mdn .../global_attributes/popover/index.md:22-24
The popover element gets NO role or accessible name from the platform.
For menu/listbox/labelled popovers, add role plus an accessible name, e.g.:
<div id="menu-popover" popover role="menu" aria-label="Actions">…</div>
mdn .../api/popover_api/using/index.md:79-86
-->
<button type="button" popovertarget="my-popover" popovertargetaction="toggle"
class="inline-flex h-9 items-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">
Open popover
</button>
<div id="my-popover" popover data-slot="popover" data-side="bottom"
class="z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] anchor-popover-bottom">
<h4 class="text-sm font-semibold">Popover content</h4>
<p class="mt-1 text-sm text-muted-foreground">
Click outside or press ESC to close. The browser handles it all.
</p>
</div>
Examples
Basic — click outside or ESC to close
Click the trigger; the browser opens the popover in the top layer. Click outside, press ESC, or click the trigger again to close.
The native Popover API was added to all major browsers in 2024. It gives us "auto-popover" behaviour (light dismiss + ESC) for free, without any state machine. Use it for contextual surfaces — settings, mini forms, info panels. Note: tooltip is a separate role, don't use Popover for hover-revealed labels.
Quick info
This panel sits in the browser's top layer. Click outside or press ESC to close.
<Button popovertarget="my-popover">Open</Button>
<Popover id="my-popover">
<h4>Quick info</h4>
<p>Click outside or press ESC.</p>
</Popover>{{ popover_trigger("Open", popover_target="my-popover", class_="…btn…") }}
{% call popover_open(id="my-popover") %}
<h4>Quick info</h4>
<p>Click outside or press ESC.</p>
{% endcall %}{{template "popover_trigger" (dict "Label" "Open" "PopoverTarget" "my-popover" "Class" "…btn…")}}
{{template "popover" (dict "ID" "my-popover" "Body" (htmlSafe `<h4>Quick info</h4><p>…</p>`))}}<.popover_trigger popover_target="my-popover" class="…btn…">Open</.popover_trigger>
<.popover id="my-popover">
<h4>Quick info</h4><p>Click outside or press ESC.</p>
</.popover><div class="flex items-center justify-center">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="default" data-size="default" popovertarget="ex-pop-1" popovertargetaction="toggle">Open popover</button>
<div id="ex-pop-1" popover="auto" data-slot="popover" data-side="bottom" class="z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
<h4 class="text-sm font-semibold">Quick info</h4>
<p class="mt-1 text-sm text-muted-foreground">
This panel sits in the browser's top layer. Click outside or press ESC to close.
</p>
</div>
</div>Mini form — interactive content
Inputs inside a popover work as expected. Unlike Tooltip, Popover MAY contain interactive content.
Filters, quick-edit panels, share dialogs — anything that needs an input plus a confirm. Tooltip is forbidden from hosting buttons or links (APG); Popover is the right primitive when you need interactive content in a hovering surface.
<Button popovertarget="edit">Edit…</Button>
<Popover id="edit">
<form class="grid gap-3">
<Label htmlFor="name">Display name</Label>
<Input id="name" name="name" />
<Button size="sm" type="submit">Save</Button>
</form>
</Popover>{{ popover_trigger("Edit…", popover_target="edit", class_="…") }}
{% call popover_open(id="edit") %}
<form>{{ label("Display name", for_="name") }}{{ input(id="name", name="name") }}{{ button("Save", type="submit") }}</form>
{% endcall %}{{template "popover_trigger" (dict "Label" "Edit…" "PopoverTarget" "edit" "Class" "…")}}
{{template "popover" (dict "ID" "edit" "Body" (htmlSafe `<form>…</form>`))}}<.popover_trigger popover_target="edit">Edit…</.popover_trigger>
<.popover id="edit">
<form>
<.label for="name">Display name</.label>
<.input id="name" name="name" />
<.button size="sm" type="submit">Save</.button>
</form>
</.popover><div class="flex items-center justify-center">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default" popovertarget="ex-pop-form" popovertargetaction="toggle">Edit display name…</button>
<div id="ex-pop-form" popover="auto" data-slot="popover" data-side="bottom" class="z-50 m-0 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] w-80">
<form class="grid gap-3">
<div class="grid gap-1.5">
<label for="ex-pop-name" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Display name</label>
<input type="text" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70" data-slot="input" id="ex-pop-name" name="name" defaultValue="Mehmet"/>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="outline" data-size="sm" popovertarget="ex-pop-form" popovertargetaction="hide">Cancel</button>
<button type="submit" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5" data-slot="button" data-variant="default" data-size="sm">Save</button>
</div>
</form>
</div>
</div>Further reading
API Reference
<Popover>
| Prop | Type | Default | Description |
|---|---|---|---|
role | string | — | ARIA role for the popover element (e.g. menu, listbox, dialog). The native popover attribute assigns no role; emitted only when provided.MDNPopover accessibility |
ariaLabelledby | string | — | Forwarded as aria-labelledby to give the popover an accessible name from another element. Emitted only when provided. |
ariaLabel | string | — | Forwarded as aria-label to give the popover an inline accessible name. Emitted only when provided. |
id* | string | — | Used by PopoverTrigger's popoverTarget. |
mode | "auto"|"manual" | "auto" | auto = light dismiss + ESC. manual = only code can toggle.MDNpopover attribute |
side | "top"|"right"|"bottom"|"left" | "bottom" | Placement relative to trigger (positioned by site.js). |
class | string | — | Extra Tailwind classes appended to the root element. |
* required