shshadcn-htmx

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.json

2. Use it

components/ui/popover.tsx
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
components/ui/popover.tsx
/** @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

templates/components/popover.html
{% 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
templates/components/popover.html
{# 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

templates/components/popover.tmpl
{{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
templates/components/popover.tmpl
{{/*
  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

lib/my_app_web/components/popover.ex
<.popover_trigger popover_target="my-popover" class="…btn…">Open</.popover_trigger>

<.popover id="my-popover">
  <p>Body content.</p>
</.popover>
View source
lib/my_app_web/components/popover.ex
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

index.html
<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
index.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;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 [&amp;:not(:popover-open)]:hidden [&amp;: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&#39;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;:not(:popover-open)]:hidden [&amp;: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 [&amp;.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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" data-slot="button" data-variant="default" data-size="sm">Save</button>
      </div>
    </form>
  </div>
</div>

API Reference

<Popover>

PropTypeDefaultDescription
rolestring
ARIA role for the popover element (e.g. menu, listbox, dialog). The native popover attribute assigns no role; emitted only when provided.MDNPopover accessibility
ariaLabelledbystring
Forwarded as aria-labelledby to give the popover an accessible name from another element. Emitted only when provided.
ariaLabelstring
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).
classstring
Extra Tailwind classes appended to the root element.

* required