Popover
Thymeleaf source
<sl:popover id="dimensions"> <sl:popover-trigger variant="outline">Open popover</sl:popover-trigger> <sl:popover-content> <sl:popover-header> <sl:popover-title>Dimensions</sl:popover-title> <sl:popover-description>Set the dimensions for the layer.</sl:popover-description> </sl:popover-header> <sl:field-group> <sl:field orientation="horizontal" for="dimensions-width"> <sl:field-label>Width</sl:field-label> <sl:input value="100%"/> </sl:field> <sl:field orientation="horizontal" for="dimensions-height"> <sl:field-label>Height</sl:field-label> <sl:input value="25px"/> </sl:field> </sl:field-group> </sl:popover-content></sl:popover>Rendered HTML
<button class="button popover-trigger" type="button" data-variant="outline" popovertarget="dimensions" aria-haspopup="dialog">Open popover</button><div class="popover-content" id="dimensions" role="dialog" popover tabindex="-1" x-data="slPopover" aria-labelledby="dimensions-title" aria-describedby="dimensions-description"> <div class="popover-header"> <div class="popover-title" id="dimensions-title">Dimensions</div> <p class="popover-description" id="dimensions-description">Set the dimensions for the layer.</p> </div> <div class="field-group"> <div class="field" data-orientation="horizontal"> <label class="label field-label" for="dimensions-width">Width</label> <input class="input" type="text" value="100%" id="dimensions-width"> </div> <div class="field" data-orientation="horizontal"> <label class="label field-label" for="dimensions-height">Height</label> <input class="input" type="text" value="25px" id="dimensions-height"> </div> </div></div>Built from parts
Section titled “Built from parts”A popover is <sl:popover> with a <sl:popover-trigger> and a <sl:popover-content>, as in shadcn/ui. The
content can hold a <sl:popover-header> with <sl:popover-title> and <sl:popover-description>, and anything
else: text, a small form, links. <sl:popover> renders no element of its own: it gives the parts their ids.
<sl:popover id="dimensions"> <sl:popover-trigger variant="outline">Open popover</sl:popover-trigger> <sl:popover-content> <sl:popover-header> <sl:popover-title>Dimensions</sl:popover-title> <sl:popover-description>Set the dimensions for the layer.</sl:popover-description> </sl:popover-header> ... </sl:popover-content></sl:popover>The panel is a popover: it is drawn above everything
else, Escape or a click outside closes it, and closing returns focus to the trigger. When it opens, focus moves to
its first control (or to an element with autofocus), or to the panel when it has none; moving the focus out of it,
with Tab past its last control for example, closes it. The trigger is a <sl:button> with popovertarget set to the
popover’s id; any other button can open it the same way.
The panel is a non-modal dialog (role="dialog"): the rest of the page stays usable. The title names it and the
description describes it (aria-labelledby and aria-describedby, with ids derived from the popover’s id:
dimensions-title). A popover without a title needs an aria-label on <sl:popover-content>. level on the title
makes it a heading.
Position
Section titled “Position”The panel opens below its trigger, centred on it. side="top|right|bottom|left" and align="start|center|end" on
<sl:popover-content> change that, and it flips to the other side when there is no room. right and left are the
end and the start of a line, so they swap on a right-to-left page.
Thymeleaf source
<sl:popover id="popover-top"> <sl:popover-trigger variant="outline">Top</sl:popover-trigger> <sl:popover-content side="top" aria-label="Top">Opens above its trigger.</sl:popover-content></sl:popover><sl:popover id="popover-right"> <sl:popover-trigger variant="outline">Right</sl:popover-trigger> <sl:popover-content side="right" aria-label="Right">Opens after its trigger.</sl:popover-content></sl:popover><sl:popover id="popover-bottom"> <sl:popover-trigger variant="outline">Bottom</sl:popover-trigger> <sl:popover-content aria-label="Bottom">Opens below its trigger.</sl:popover-content></sl:popover><sl:popover id="popover-left"> <sl:popover-trigger variant="outline">Left</sl:popover-trigger> <sl:popover-content side="left" aria-label="Left">Opens before its trigger.</sl:popover-content></sl:popover>Rendered HTML
<button class="button popover-trigger" type="button" data-variant="outline" popovertarget="popover-top" aria-haspopup="dialog">Top</button><div class="popover-content" id="popover-top" role="dialog" popover tabindex="-1" x-data="slPopover" data-side="top" aria-label="Top">Opens above its trigger.</div><button class="button popover-trigger" type="button" data-variant="outline" popovertarget="popover-right" aria-haspopup="dialog">Right</button><div class="popover-content" id="popover-right" role="dialog" popover tabindex="-1" x-data="slPopover" data-side="right" aria-label="Right">Opens after its trigger.</div><button class="button popover-trigger" type="button" data-variant="outline" popovertarget="popover-bottom" aria-haspopup="dialog">Bottom</button><div class="popover-content" id="popover-bottom" role="dialog" popover tabindex="-1" x-data="slPopover" aria-label="Bottom">Opens below its trigger.</div><button class="button popover-trigger" type="button" data-variant="outline" popovertarget="popover-left" aria-haspopup="dialog">Left</button><div class="popover-content" id="popover-left" role="dialog" popover tabindex="-1" x-data="slPopover" data-side="left" aria-label="Left">Opens before its trigger.</div>It is placed with CSS anchor positioning, which every current browser supports (Chrome and Edge 133, Safari 26, Firefox 147). In older ones the popover’s small Alpine component places it from the trigger’s position instead.
A form in a popover, with htmx
Section titled “A form in a popover, with htmx”A form in the panel can post with htmx and swap only what it changes. An HX-Trigger: sl-popover-close response
header closes the popover after a valid save, and focus returns to the trigger:
<h2 id="team-name" th:text="${team.name}">Design</h2><sl:popover id="rename-team"> <sl:popover-trigger variant="outline" size="sm">Rename</sl:popover-trigger> <sl:popover-content> <sl:popover-header><sl:popover-title>Rename team</sl:popover-title></sl:popover-header> <form th:replace="~{team :: rename-form}"></form> </sl:popover-content></sl:popover>@HxRequest@PostMapping("/team/name")public String rename(@Valid @ModelAttribute("renameForm") RenameForm form, BindingResult bindingResult, HtmxResponse htmxResponse, Model model) { if (bindingResult.hasErrors()) { return "team :: rename-form"; // the form swaps itself: the popover stays open } model.addAttribute("team", teamService.rename(form.getName())); htmxResponse.addTrigger("sl-popover-close"); return "team :: team-name-oob"; // the heading, with hx-swap-oob="true"}htmx fires sl-popover-close on the form; it bubbles up to the popover, which closes. A popover nested in another
closes only itself. Back never brings a popover back open: whether it is open is not part of the markup htmx saves in
its history cache.
Left out
Section titled “Left out”PopoverAnchor: the popover is placed against its trigger.PopoverPortal: the top layer does what the portal is for.- State in the browser (
open/onOpenChange): the trigger opens it; thesl-popover-closeevent closes it. modal: a popover never makes the rest of the page inert. Use a dialog for that.
Attributes
Section titled “Attributes”sl:popover
Section titled “sl:popover”| Attribute | Values | Default | Description |
|---|---|---|---|
id | string | required | The popover's id, which the trigger points at with popovertarget. The title and description ids derive from it. |
Every prop can also be set from an expression: th:id="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:popover-trigger
Section titled “sl:popover-trigger”| Attribute | Values | Default | Description |
|---|---|---|---|
variant | default outline ghost destructive secondary link | default | Visual style of the button, as on sl:button. |
size | xs sm default lg icon icon-xs icon-sm icon-lg | default | Size of the button, as on sl:button. The icon sizes need an aria-label. |
Every prop can also be set from an expression: th:variant="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:popover-content
Section titled “sl:popover-content”| Attribute | Values | Default | Description |
|---|---|---|---|
side | top right bottom left | bottom | The side of the trigger it opens on. right and left are the end and the start of a line: on a right-to-left page a right popover opens on the left. |
align | start center end | center | How it lines up with the trigger along that side. |
Every prop can also be set from an expression: th:side="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:popover-header
Section titled “sl:popover-header”sl:popover-header declares no props; every attribute passes through.
sl:popover-title
Section titled “sl:popover-title”| Attribute | Values | Default | Description |
|---|---|---|---|
level | 1 2 3 4 5 6 | — | Heading level. With it the title is a heading (role="heading" and aria-level); without it, plain text, as in shadcn/ui. |
Every prop can also be set from an expression: th:level="${...}". Any other attribute passes through to the rendered element, and class is appended.
sl:popover-description
Section titled “sl:popover-description”sl:popover-description declares no props; every attribute passes through.
JavaScript
Section titled “JavaScript”A small Alpine component, slPopover, adds what the browser’s popover lacks: <sl:popover-content> renders as
<div class="popover-content" role="dialog" popover x-data="slPopover">.
- The basics work without Alpine. The trigger opens and closes the popover, and Escape and a click outside close
it. Alpine is needed for moving the focus into it when it opens, closing it when the focus leaves, the
sl-popover-closeevent,aria-expandedon the trigger, and placing it in browsers without CSS anchor positioning. Alpine is included by default; see Alpine. - It needs no Alpine plugin. If you load Alpine yourself (
shadleaf.assets.alpine=external), plain Alpine is enough. - Keep your own
x-dataoff<sl:popover-content>. An element has onex-data, and attributes on it end up on the panel, so yours would replaceslPopover. Put it on an element inside the panel.
| Event | On | Effect |
|---|---|---|
sl-popover-close |
the popover, or anything inside it (it bubbles) | Closes the popover. |
toggle |
the popover (the browser’s own) | Fired when it opens or closes, however that happened. |