Skip to content

Popover

PopoverA small form next to its trigger. Focus moves to the first field when it opens.
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>

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.

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.

Sidesside puts it on a side of its trigger; it flips when there is no room there.
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 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.

  • 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; the sl-popover-close event closes it.
  • modal: a popover never makes the rest of the page inert. Use a dialog for that.
AttributeValuesDefaultDescription
idstringrequiredThe 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.

AttributeValuesDefaultDescription
variantdefault outline ghost destructive secondary linkdefaultVisual style of the button, as on sl:button.
sizexs sm default lg icon icon-xs icon-sm icon-lgdefaultSize 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.

AttributeValuesDefaultDescription
sidetop right bottom leftbottomThe 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.
alignstart center endcenterHow 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 declares no props; every attribute passes through.

AttributeValuesDefaultDescription
level1 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 declares no props; every attribute passes through.

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-close event, aria-expanded on 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-data off <sl:popover-content>. An element has one x-data, and attributes on it end up on the panel, so yours would replace slPopover. 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.