Popover

A floating panel for arbitrary rich content, triggered by click or hover, with positioning control, click-outside and escape to close.

Basic Usage

A popover wraps a trigger slot (the clickable element) and a default slot (the floating panel). Clicking the trigger toggles the panel; clicking outside or pressing Esc closes it.

<c-ui.popover>
    <c-slot name="trigger"><c-ui.button>Open popover</c-ui.button></c-slot>
    <div class="font-medium mb-1">Dimensions</div>
    <p class="text-zinc-600 dark:text-zinc-400">Set the width and height of the selected layer in pixels.</p>
</c-ui.popover>
{% cotton ui.popover %}
    {% cotton:slot trigger %}{% cotton ui.button %}Open popover{% endcotton %}{% endcotton:slot %}
    <div class="font-medium mb-1">Dimensions</div>
    <p class="text-zinc-600 dark:text-zinc-400">Set the width and height of the selected layer in pixels.</p>
{% endcotton %}

Positions

Use the position prop to place the panel on the top, bottom, left or right of the trigger. The panel flips to the opposite side when there is not enough room in the viewport. Adjust the gap with :offset.

<c-ui.popover position="top">
    <c-slot name="trigger"><c-ui.button :outlined="True">Top</c-ui.button></c-slot>
    <p>Panel above the trigger.</p>
</c-ui.popover>

<c-ui.popover position="bottom">
    <c-slot name="trigger"><c-ui.button :outlined="True">Bottom</c-ui.button></c-slot>
    <p>Panel below the trigger.</p>
</c-ui.popover>

<c-ui.popover position="left">
    <c-slot name="trigger"><c-ui.button :outlined="True">Left</c-ui.button></c-slot>
    <p>Panel to the left.</p>
</c-ui.popover>

<c-ui.popover position="right">
    <c-slot name="trigger"><c-ui.button :outlined="True">Right</c-ui.button></c-slot>
    <p>Panel to the right.</p>
</c-ui.popover>
{% cotton ui.popover position="top" %}
    {% cotton:slot trigger %}{% cotton ui.button :outlined="True" %}Top{% endcotton %}{% endcotton:slot %}
    <p>Panel above the trigger.</p>
{% endcotton %}

{% cotton ui.popover position="bottom" %}
    {% cotton:slot trigger %}{% cotton ui.button :outlined="True" %}Bottom{% endcotton %}{% endcotton:slot %}
    <p>Panel below the trigger.</p>
{% endcotton %}

{% cotton ui.popover position="left" %}
    {% cotton:slot trigger %}{% cotton ui.button :outlined="True" %}Left{% endcotton %}{% endcotton:slot %}
    <p>Panel to the left.</p>
{% endcotton %}

{% cotton ui.popover position="right" %}
    {% cotton:slot trigger %}{% cotton ui.button :outlined="True" %}Right{% endcotton %}{% endcotton:slot %}
    <p>Panel to the right.</p>
{% endcotton %}

With Rich Content

Unlike a tooltip, a popover can hold any markup: headings, forms, inputs and buttons. Use the class prop to widen the panel for richer layouts.

<c-ui.popover class="max-w-sm w-80">
    <c-slot name="trigger"><c-ui.button>Edit profile</c-ui.button></c-slot>

    <div class="font-medium mb-1">Profile</div>
    <p class="text-zinc-600 dark:text-zinc-400 mb-4">Update your display name and contact email.</p>

    <div class="space-y-3">
        <c-ui.input label="Name" name="name" value="Ada Lovelace" />
        <c-ui.input label="Email" name="email" type="email" value="ada@example.com" />
    </div>

    <div class="flex justify-end gap-2 mt-4">
        <c-ui.button :outlined="True" x-on:click="close()">Cancel</c-ui.button>
        <c-ui.button x-on:click="close()">Save</c-ui.button>
    </div>
</c-ui.popover>
{% cotton ui.popover class="max-w-sm w-80" %}
    {% cotton:slot trigger %}{% cotton ui.button %}Edit profile{% endcotton %}{% endcotton:slot %}

    <div class="font-medium mb-1">Profile</div>
    <p class="text-zinc-600 dark:text-zinc-400 mb-4">Update your display name and contact email.</p>

    <div class="space-y-3">
        {% cotton ui.input label="Name" name="name" value="Ada Lovelace" /%}
        {% cotton ui.input label="Email" name="email" type="email" value="ada@example.com" /%}
    </div>

    <div class="flex justify-end gap-2 mt-4">
        {% cotton ui.button :outlined="True" x-on:click="close()" %}Cancel{% endcotton %}
        {% cotton ui.button x-on:click="close()" %}Save{% endcotton %}
    </div>
{% endcotton %}

The panel exposes the popover's Alpine scope, so inner controls can call close() directly, for example to dismiss the panel after a Save or Cancel action.

Hover Trigger

Set open_on="hover" to open the popover on hover instead of click, the "hovercard" pattern seen on social profiles. A short intent delay avoids opening on a passing cursor, and a close delay lets you move from the trigger into the panel without it snapping shut. Tune them with :open_delay and :close_delay (milliseconds).

<c-ui.popover open_on="hover" position="top" :open_delay="120" :close_delay="200" class="w-64">
    <c-slot name="trigger"><a href="#" @click.prevent class="text-accent font-medium no-underline cursor-pointer">@ada</a></c-slot>
    <div class="flex items-center gap-3">
        <c-ui.avatar initials="AL" color="violet" />
        <div>
            <div class="font-medium text-zinc-900 dark:text-zinc-100">Ada Lovelace</div>
            <div class="text-zinc-500 dark:text-zinc-400">Mathematician</div>
        </div>
    </div>
</c-ui.popover>
{% cotton ui.popover open_on="hover" position="top" :open_delay="120" :close_delay="200" class="w-64" %}
    {% cotton:slot trigger %}<a href="#" @click.prevent class="text-accent font-medium no-underline cursor-pointer">@ada</a>{% endcotton:slot %}
    <div class="flex items-center gap-3">
        {% cotton ui.avatar initials="AL" color="violet" /%}
        <div>
            <div class="font-medium text-zinc-900 dark:text-zinc-100">Ada Lovelace</div>
            <div class="text-zinc-500 dark:text-zinc-400">Mathematician</div>
        </div>
    </div>
{% endcotton %}

Load on Open

The popover dispatches a popover-open event (and popover-close) when it opens, so you can load the panel's contents only when needed, for example fetching a profile card the first time it is shown. Listen for the event on the tag or any ancestor, where your own Alpine scope is in reach. The example below fakes a fetch each time it opens, showing a spinner while it loads.

<div
    x-data="{ loaded: false, loading: false, load() { this.loaded = false; this.loading = true; fetch('/api/profile/1').then(r => r.json()).then(d => { this.data = d; this.loaded = true; this.loading = false; }); } }"
    @popover-open="load()"
>
    <c-ui.popover open_on="hover" position="top" class="w-64">
        <c-slot name="trigger"><c-ui.button :outlined="True">Hover to load</c-ui.button></c-slot>
        <template x-if="loading">
            <div class="flex items-center gap-2 text-zinc-500"><c-ui.spinner size="sm" color="current" /> Loading...</div>
        </template>
        <template x-if="loaded">
            <div x-text="data.name"></div>
        </template>
    </c-ui.popover>
</div>
<div
    x-data="{ loaded: false, loading: false, load() { this.loaded = false; this.loading = true; fetch('/api/profile/1').then(r => r.json()).then(d => { this.data = d; this.loaded = true; this.loading = false; }); } }"
    @popover-open="load()"
>
    {% cotton ui.popover open_on="hover" position="top" class="w-64" %}
        {% cotton:slot trigger %}{% cotton ui.button :outlined="True" %}Hover to load{% endcotton %}{% endcotton:slot %}
        <template x-if="loading">
            <div class="flex items-center gap-2 text-zinc-500">{% cotton ui.spinner size="sm" color="current" /%} Loading...</div>
        </template>
        <template x-if="loaded">
            <div x-text="data.name"></div>
        </template>
    {% endcotton %}
</div>

For an HTMX panel, load on the same event instead: put hx-get="/profile/1" hx-trigger="popover-open once from:closest [data-popover]" on the content, and it fetches when the popover first opens.

Popover vs Tooltip vs Dropdown

  • Tooltip a hint: short read-only text on hover.
  • Dropdown a menu: a list of actions or links to pick from.
  • Popover rich interactive content: a form, settings or a hovercard.

API Reference

Popover Props
Name Description Type Options Default
position Side of the trigger the panel opens on. Flips to the opposite side when there is no room. str top, bottom, left, right bottom
open_on How the panel opens. Hover adds intent delays and is the hovercard pattern. str click, hover click
offset Gap in pixels between the trigger and the panel. int Any number 8
open_delay Hover mode only. Milliseconds the cursor must rest on the trigger before opening. int Any number 120
close_delay Hover mode only. Milliseconds before closing after the cursor leaves, so you can move into the panel. int Any number 200
class Extra classes appended to the panel, for example to set its width. str Any Tailwind classes
Events
Name Description Type Options Default
popover-open Dispatched when the panel opens. Listen with @popover-open on the tag or an ancestor to lazy-load content. event
popover-close Dispatched when the panel closes. event
Popover Slots
Name Description Type Options Default
trigger The clickable element that toggles the panel. Optional
slot (default) The floating panel content. Any rich markup, including forms and buttons. Required
Accessibility

Escape and outside-click close the panel. The trigger sets aria-expanded and the panel uses role="dialog".