Displays rich content in a portal, triggered by a button.
<Popover.root id="popover-primitive">
<Popover.trigger
id="popover-trigger"
content_id="popover-content"
class="DemoIconButton"
aria-label="Update dimensions"
>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
aria-hidden="true"
>
<path
d="M5.5 3v9M9.5 3v9M3 5.5h9M3 9.5h9"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</Popover.trigger>
<Popover.content
id="popover-content"
class="DemoPopoverContent"
side_offset={5}
>
<div style="display: flex; flex-direction: column; gap: 10px;">
<p
class="DemoText"
style="margin-bottom: 10px;"
>
Dimensions
</p>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="popover-width"
>
Width
</label>
<input
class="DemoInput"
id="popover-width"
value="100%"
/>
</fieldset>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="popover-maxWidth"
>
Max. width
</label>
<input
class="DemoInput"
id="popover-maxWidth"
value="300px"
/>
</fieldset>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="popover-height"
>
Height
</label>
<input
class="DemoInput"
id="popover-height"
value="25px"
/>
</fieldset>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="popover-maxHeight"
>
Max. height
</label>
<input
class="DemoInput"
id="popover-maxHeight"
value="none"
/>
</fieldset>
</div>
<Popover.close
class="DemoPopoverClose"
aria-label="Close"
>
×
</Popover.close>
<Popover.arrow class="DemoPopoverArrow" />
</Popover.content>
</Popover.root>
/* reset */
button,
fieldset,
input {
all: unset;
}
.DemoPopoverContent {
border-radius: 4px;
padding: 20px;
width: 260px;
background-color: white;
box-shadow:
hsl(206 22% 7% / 35%) 0px 10px 38px -10px,
hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.DemoPopoverContent:focus {
box-shadow:
hsl(206 22% 7% / 35%) 0px 10px 38px -10px,
hsl(206 22% 7% / 20%) 0px 10px 20px -15px,
0 0 0 2px var(--violet-7);
}
.DemoPopoverContent[data-state="open"][data-side="top"] {
animation-name: slideDownAndFade;
}
.DemoPopoverContent[data-state="open"][data-side="right"] {
animation-name: slideLeftAndFade;
}
.DemoPopoverContent[data-state="open"][data-side="bottom"] {
animation-name: slideUpAndFade;
}
.DemoPopoverContent[data-state="open"][data-side="left"] {
animation-name: slideRightAndFade;
}
.DemoPopoverArrow {
fill: white;
}
.DemoPopoverClose {
font-family: inherit;
border-radius: 100%;
height: 25px;
width: 25px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--violet-11);
position: absolute;
top: 5px;
right: 5px;
}
.DemoPopoverClose:hover {
background-color: var(--violet-4);
}
.DemoPopoverClose:focus {
box-shadow: 0 0 0 2px var(--violet-7);
}
.DemoIconButton {
font-family: inherit;
border-radius: 100%;
height: 35px;
width: 35px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--violet-11);
background-color: white;
box-shadow: 0 2px 10px var(--black-a7);
}
.DemoIconButton:hover {
background-color: var(--violet-3);
}
.DemoIconButton:focus {
box-shadow: 0 0 0 2px black;
}
.DemoFieldset {
display: flex;
gap: 20px;
align-items: center;
}
.DemoLabel {
font-size: 13px;
color: var(--violet-11);
width: 75px;
}
.DemoInput {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
border-radius: 4px;
padding: 0 10px;
font-size: 13px;
line-height: 1;
color: var(--violet-11);
box-shadow: 0 0 0 1px var(--violet-7);
height: 25px;
}
.DemoInput:focus {
box-shadow: 0 0 0 2px var(--violet-8);
}
.DemoText {
margin: 0;
color: var(--mauve-12);
font-size: 15px;
line-height: 19px;
font-weight: 500;
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.DemoButton {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
padding: 0 15px;
font-size: 15px;
line-height: 1;
font-weight: 500;
height: 35px;
background-color: white;
color: var(--violet-11);
box-shadow: 0 2px 10px var(--black-a7);
}
.DemoButton:hover {
background-color: var(--violet-3);
}
.DemoButton:focus {
box-shadow: 0 0 0 2px black;
}
.DemoPopoverRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
Anatomy
Import all parts and piece them together.
<Popover.root id="…">
<Popover.trigger content_id="…" />
<Popover.anchor />
<Popover.portal id="…">
<Popover.content id="…">
<Popover.close />
<Popover.arrow />
</Popover.content>
</Popover.portal>
</Popover.root>
Anatomy
-
Root - Contains all the parts of a popover.
-
Trigger - The button that toggles the popover. Set `content_id` to the content element's `id`. By default, content positions against the trigger.
-
Anchor - An optional element to position content against instead of the trigger.
-
Portal - When used, portals the content part into the target (default `body`).
-
Content - The component that pops out when the popover is open.
-
Arrow - An optional arrow rendered inside `Popover.content`.
-
Close - The button that closes an open popover.
API Reference
Root
Contains all the parts of a popover.
Use open with on_open_change for controlled open state in LiveView:
<Popover.root id="dims-popover" open={@open} on_open_change="popover_open_change">
…
</Popover.root>
def handle_event("popover_open_change", %{"open" => open}, socket) do
{:noreply, assign(socket, :open, open)}
end
| Prop | Type | Default | Description |
|---|---|---|---|
default_open
|
boolean
|
false
|
|
id
|
string
|
—
|
|
modal
|
boolean
|
false
|
|
on_open_change
|
string
|
nil
|
|
open
|
boolean
|
false
|
default_open
boolean
Default
false
id
string
Default
—
modal
boolean
Default
false
on_open_change
string
Default
nil
open
boolean
Default
false
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the popover is open. |
[data-modal]
|
Present when modal
|
Present when `modal` is true. |
Trigger
The button that toggles the popover. By default, Popover.content positions itself against the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
as
|
string
|
"button"
|
|
content_id
|
string
|
nil
|
|
id
|
string
|
nil
|
as
string
Default
"button"
content_id
string
Default
nil
id
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the popover is open. |
Anchor
An optional element to position Popover.content against. If omitted, content positions alongside Popover.trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Portal
When used, portals the content part into the target (default body).
| Prop | Type | Default | Description |
|---|---|---|---|
class
|
any
|
nil
|
|
container
|
string
|
"div"
|
|
id
|
string
|
—
|
|
target
|
string
|
"body"
|
class
any
Default
nil
container
string
Default
"div"
id
string
Default
—
target
string
Default
"body"
Content
The component that pops out when the popover is open.
| Prop | Type | Default | Description |
|---|---|---|---|
align
|
string
|
"center"
|
|
id
|
string
|
nil
|
|
side
|
string
|
"bottom"
|
|
side_offset
|
integer
|
8
|
align
string
Default
"center"
id
string
Default
nil
side
string
Default
"bottom"
side_offset
integer
Default
8
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the popover is open. |
[data-side]
|
top | right | bottom | left
|
Preferred side relative to the anchor. |
[data-align]
|
start | center | end
|
Alignment along the side. |
Arrow
An optional arrow element to render alongside the popover. Must be rendered inside Popover.content.
| Prop | Type | Default | Description |
|---|---|---|---|
height
|
integer
|
5
|
|
id
|
string
|
nil
|
|
width
|
integer
|
10
|
height
integer
Default
5
id
string
Default
nil
width
integer
Default
10
Close
The button that closes an open popover.
| Prop | Type | Default | Description |
|---|---|---|---|
as
|
string
|
"button"
|
|
id
|
string
|
nil
|
as
string
Default
"button"
id
string
Default
nil
Examples
Constrain the content size
You may want to constrain the width or height of the content so it does not exceed the viewport:
<Popover.content id="popover-content" class="DemoPopoverContent" side_offset={5}>
…
</Popover.content>
.DemoPopoverContent {
width: 260px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
overflow-y: auto;
}
Origin-aware animations
Animate with CSS against data-state — see the animation guide:
.DemoPopoverContent {
animation-duration: 0.5s;
animation-timing-function: ease-out;
}
.DemoPopoverContent[data-state="open"] {
animation-name: scaleIn;
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
Collision-aware animations
Popover.content exposes data-side and data-align for direction-aware animations based on the configured placement:
<Popover.content id="popover-content" class="DemoPopoverContent" side="bottom" align="center">
…
</Popover.content>
.DemoPopoverContent {
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.DemoPopoverContent[data-side="top"] {
animation-name: slideUp;
}
.DemoPopoverContent[data-side="bottom"] {
animation-name: slideDown;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
With custom anchor
Anchor content to another element when the trigger should not be the positioning reference:
<Popover.root id="anchor-popover">
<Popover.anchor class="DemoPopoverRow">
Row as anchor
<Popover.trigger id="anchor-popover-trigger" content_id="anchor-popover-content" class="DemoButton">
Trigger
</Popover.trigger>
</Popover.anchor>
<Popover.content id="anchor-popover-content" class="DemoPopoverContent">
…
</Popover.content>
</Popover.root>
.DemoPopoverRow {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
background: var(--mauve-3);
border-radius: 6px;
}
Accessibility
Adheres to the Dialog WAI-ARIA design pattern.
Keyboard Interactions
Keyboard Interactions
| Key | Description |
|---|---|
Space
|
Opens/closes the popover. |
Enter
|
Opens/closes the popover. |
Tab
|
Moves focus to the next focusable element. |
Shift + Tab
|
Moves focus to the previous focusable element. |
Escape
|
Closes the popover and moves focus to `Popover.trigger`. |
Custom APIs
Create your own API by abstracting the primitive parts into your own component.
Abstract the arrow and set default configuration
This example abstracts Popover.arrow and sets a default side_offset.
Usage
<.popover id="settings-popover">
<:trigger>Popover trigger</:trigger>
<:content>Popover content</:content>
</.popover>
Implementation
slot :trigger, required: true
slot :content, required: true
attr :id, :string, required: true
def popover(assigns) do
~H"""
<Popover.root id={@id}>
<Popover.trigger id={"#{@id}-trigger"} content_id={"#{@id}-content"} class="DemoButton">
{render_slot(@trigger)}
</Popover.trigger>
<Popover.content id={"#{@id}-content"} class="DemoPopoverContent" side_offset={5}>
{render_slot(@content)}
<Popover.arrow class="DemoPopoverArrow" />
</Popover.content>
</Popover.root>
"""
end