Skip to content

Alert Dialog

Alert dialogA question the user has to answer. Cancel has the focus when it opens.
Thymeleaf source
<sl:button variant="outline" commandfor="delete-account" command="show-modal">Delete account</sl:button>
<sl:alert-dialog id="delete-account">
<sl:alert-dialog-header>
<sl:alert-dialog-title>Are you absolutely sure?</sl:alert-dialog-title>
<sl:alert-dialog-description>
This action cannot be undone. This will permanently delete your account and remove your data from our
servers.
</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel/>
<sl:button>Continue</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>
Rendered HTML
<button class="button" type="button" data-variant="outline" commandfor="delete-account" command="show-modal">Delete account</button>
<dialog class="alert-dialog" role="alertdialog" id="delete-account" closedby="closerequest" x-data="slDialog" aria-labelledby="delete-account-title" aria-describedby="delete-account-description">
<div class="alert-dialog-header">
<div class="alert-dialog-title" role="heading" aria-level="2" id="delete-account-title">Are you absolutely sure?</div>
<div class="alert-dialog-description" id="delete-account-description">This action cannot be undone. This will permanently delete your account and remove your data from our servers.</div>
</div>
<div class="alert-dialog-footer">
<button class="button alert-dialog-cancel" type="button" data-variant="outline" commandfor="delete-account" command="close" autofocus>Cancel</button>
<button class="button" type="button">Continue</button>
</div>
</dialog>

An alert dialog asks a question the user has to answer before going on, such as “Delete this account?”. It is <sl:alert-dialog> plus its parts, as in shadcn/ui: <sl:alert-dialog-header> (holding an optional <sl:alert-dialog-media>, <sl:alert-dialog-title> and <sl:alert-dialog-description>) and <sl:alert-dialog-footer> with <sl:alert-dialog-cancel> and the action, a plain <sl:button>.

<sl:button variant="outline" commandfor="delete-account" command="show-modal">Delete account</sl:button>
<sl:alert-dialog id="delete-account">
<sl:alert-dialog-header>
<sl:alert-dialog-title>Are you absolutely sure?</sl:alert-dialog-title>
<sl:alert-dialog-description>This action cannot be undone.</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel/>
<sl:button>Continue</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>

It works like a dialog, with the differences an alert dialog needs:

  • It has role="alertdialog", so a screen reader announces it as an alert and reads the description with the title.
  • A click outside does not close it: the user has to choose. Escape still does, as in Radix, and counts as cancel.
  • It has no close button in the corner.
  • <sl:alert-dialog-cancel> takes the focus when it opens, so pressing Enter straight away never confirms by accident. It closes the dialog (commandfor and command="close" are set for you) and says “Cancel” unless you give it text of its own (the default comes from the message sl.alert-dialog.cancel). It takes the button’s variant (default outline) and size, and passes any other attribute on to the button.

The action has no part of its own: what it does depends on the page. A type="submit" button in a form, a button with hx-delete, or one with commandfor and command="close" when closing is all it has to do.

size="sm" makes it narrower, centres the header and puts the two buttons side by side in equal columns. <sl:alert-dialog-media> shows an icon above the title.

Small, with mediasize="sm" centres the header and puts the two buttons side by side; the media shows an icon above the title.
Thymeleaf source
<sl:button variant="outline" commandfor="allow-accessory" command="show-modal">Connect accessory</sl:button>
<sl:alert-dialog id="allow-accessory" size="sm">
<sl:alert-dialog-header>
<sl:alert-dialog-media><sl:icon name="bluetooth"/></sl:alert-dialog-media>
<sl:alert-dialog-title>Allow accessory to connect?</sl:alert-dialog-title>
<sl:alert-dialog-description>
Do you want to allow the USB accessory to connect to this device?
</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel>Don't allow</sl:alert-dialog-cancel>
<sl:button>Allow</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>
Rendered HTML
<button class="button" type="button" data-variant="outline" commandfor="allow-accessory" command="show-modal">Connect accessory</button>
<dialog class="alert-dialog" role="alertdialog" id="allow-accessory" closedby="closerequest" x-data="slDialog" aria-labelledby="allow-accessory-title" aria-describedby="allow-accessory-description" data-size="sm">
<div class="alert-dialog-header">
<div class="alert-dialog-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m7 7 10 10-5 5V2l5 5L7 17"></path>
</svg>
</div>
<div class="alert-dialog-title" role="heading" aria-level="2" id="allow-accessory-title">Allow accessory to connect?</div>
<div class="alert-dialog-description" id="allow-accessory-description">Do you want to allow the USB accessory to connect to this device?</div>
</div>
<div class="alert-dialog-footer">
<button class="button alert-dialog-cancel" type="button" data-variant="outline" commandfor="allow-accessory" command="close" autofocus>Don&apos;t allow</button>
<button class="button" type="button">Allow</button>
</div>
</dialog>

In a default-size dialog on a wide screen, the media sits beside the title and description.

MediaIn a default-size dialog on a wide screen, the media sits beside the title and description.
Thymeleaf source
<sl:button variant="outline" commandfor="share-project" command="show-modal">Share project</sl:button>
<sl:alert-dialog id="share-project">
<sl:alert-dialog-header>
<sl:alert-dialog-media><sl:icon name="circle-fading-plus"/></sl:alert-dialog-media>
<sl:alert-dialog-title>Share this project?</sl:alert-dialog-title>
<sl:alert-dialog-description>
Anyone with the link will be able to view and edit this project.
</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel/>
<sl:button>Share</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>
Rendered HTML
<button class="button" type="button" data-variant="outline" commandfor="share-project" command="show-modal">Share project</button>
<dialog class="alert-dialog" role="alertdialog" id="share-project" closedby="closerequest" x-data="slDialog" aria-labelledby="share-project-title" aria-describedby="share-project-description">
<div class="alert-dialog-header">
<div class="alert-dialog-media">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 2a10 10 0 0 1 7.38 16.75"></path>
<path d="M12 8v8"></path>
<path d="M16 12H8"></path>
<path d="M2.5 8.875a10 10 0 0 0-.5 3"></path>
<path d="M2.83 16a10 10 0 0 0 2.43 3.4"></path>
<path d="M4.636 5.235a10 10 0 0 1 .891-.857"></path>
<path d="M8.644 21.42a10 10 0 0 0 7.631-.38"></path>
</svg>
</div>
<div class="alert-dialog-title" role="heading" aria-level="2" id="share-project-title">Share this project?</div>
<div class="alert-dialog-description" id="share-project-description">Anyone with the link will be able to view and edit this project.</div>
</div>
<div class="alert-dialog-footer">
<button class="button alert-dialog-cancel" type="button" data-variant="outline" commandfor="share-project" command="close" autofocus>Cancel</button>
<button class="button" type="button">Share</button>
</div>
</dialog>

For an action that deletes something, give the action variant="destructive", and the media too.

DestructiveA destructive action, with a destructive media square.
Thymeleaf source
<sl:button variant="destructive" commandfor="delete-chat" command="show-modal">Delete chat</sl:button>
<sl:alert-dialog id="delete-chat" size="sm">
<sl:alert-dialog-header>
<sl:alert-dialog-media variant="destructive"><sl:icon name="trash-2"/></sl:alert-dialog-media>
<sl:alert-dialog-title>Delete chat?</sl:alert-dialog-title>
<sl:alert-dialog-description>
This will permanently delete this chat conversation. View <a href="#">Settings</a> to delete any
memories saved during this chat.
</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel/>
<sl:button variant="destructive">Delete</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>
Rendered HTML
<button class="button" type="button" data-variant="destructive" commandfor="delete-chat" command="show-modal">Delete chat</button>
<dialog class="alert-dialog" role="alertdialog" id="delete-chat" closedby="closerequest" x-data="slDialog" aria-labelledby="delete-chat-title" aria-describedby="delete-chat-description" data-size="sm">
<div class="alert-dialog-header">
<div class="alert-dialog-media" data-variant="destructive">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</div>
<div class="alert-dialog-title" role="heading" aria-level="2" id="delete-chat-title">Delete chat?</div>
<div class="alert-dialog-description" id="delete-chat-description">
This will permanently delete this chat conversation. View
<a href="#">Settings</a>
to delete any memories saved during this chat.
</div>
</div>
<div class="alert-dialog-footer">
<button class="button alert-dialog-cancel" type="button" data-variant="outline" commandfor="delete-chat" command="close" autofocus>Cancel</button>
<button class="button" type="button" data-variant="destructive">Delete</button>
</div>
</dialog>

The alert dialog can come from the server, as the dialog does: a Delete button in a row fetches it into an empty element on the page, and open shows it as soon as it is there.

<!-- In the layout -->
<div id="modal-root"></div>
<!-- In the row -->
<sl:button variant="outline" hx:get="@{/members/{id}/delete(id=${member.id})}" hx-target="#modal-root">
Delete
</sl:button>

The fragment has the question, and the destructive button sends the request:

<sl:alert-dialog th:fragment="delete-dialog" id="delete-member" size="sm" open>
<sl:alert-dialog-header>
<sl:alert-dialog-media variant="destructive"><sl:icon name="trash-2"/></sl:alert-dialog-media>
<sl:alert-dialog-title th:text="|Delete ${member.name}?|">Delete Ada Lovelace?</sl:alert-dialog-title>
<sl:alert-dialog-description>They lose access to the team at once.</sl:alert-dialog-description>
</sl:alert-dialog-header>
<sl:alert-dialog-footer>
<sl:alert-dialog-cancel/>
<sl:button variant="destructive" hx:delete="@{/members/{id}(id=${member.id})}"
th:hx-target="|#member-${member.id}|" hx-swap="outerHTML">Delete</sl:button>
</sl:alert-dialog-footer>
</sl:alert-dialog>

The controller deletes, closes the dialog with the sl-dialog-close event (the same one that closes a dialog), says what happened in a toast and answers with nothing for the row, which removes it:

@HxRequest
@DeleteMapping("/members/{id}")
public String delete(@PathVariable long id, HtmxRequest htmxRequest, HtmxResponse htmxResponse, Model model) {
List<Member> rows = memberService.list(sortOf(htmxRequest.getCurrentUrl())); // as the page shows them
Member member = memberService.delete(id);
htmxResponse.addTrigger("sl-dialog-close");
htmxResponse.addTrigger("sl-toast", Toast.success("%s was deleted".formatted(member.name())));
Member next = neighbour(rows, member); // the next row, the previous one after the last, or null
if (next == null) {
return "members :: invite-button-focus";
}
model.addAttribute("member", next);
return "members :: member-row-focus"; // the next row, out of band, its button focused
}

Closing returns focus to the Delete button that opened the dialog, but the swap then removes that button with its row, and focus would fall back to the top of the page. The toast cannot take it instead: it goes away after a few seconds, and the focus would fall with it. So the response moves the focus to what comes next: the next row’s button, re-rendered out of band with autofocus, which htmx focuses after the swap. After the last row that is the row before it, and once the table is empty, the button that adds a member. The toast is announced, so a screen reader user hears what happened and is where they can go on.

<th:block th:fragment="member-row-focus" th:with="oob=true, focus=true">
<tr th:replace="~{members :: member-row}"></tr>
</th:block>
<tr th:fragment="member-row" th:id="|member-${member.id}|"
th:attr="hx-swap-oob=${oob != null and oob ? 'true' : null}">
<td th:text="${member.name}">Ada Lovelace</td>
<td>
<sl:button variant="outline" hx:get="@{/members/{id}/delete(id=${member.id})}" hx-target="#modal-root"
th:autofocus="${focus != null and focus}">Delete</sl:button>
</td>
</tr>

“Next” is the next row as the page shows it: htmx sends the page’s address in the HX-Current-URL header, so the controller can read the sort order from it, as sample-01’s /dialog page does.

Without JavaScript the same buttons need a page of their own for the question, or a form per row with <sl:alert-dialog> in the page and a type="submit" action.

AttributeValuesDefaultDescription
idstringrequiredThe dialog's id, which the button that opens it points at with commandfor. The title and description ids derive from it.
sizedefault smdefaultSize of the dialog. sm is narrower, with the header centred and the footer's two buttons side by side.
openboolean—Open the dialog as soon as it is on the page, for a dialog the server renders in answer to a request (an htmx swap). Needs Alpine.

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:alert-dialog-header declares no props; every attribute passes through.

AttributeValuesDefaultDescription
variantdefault destructivedefaultColour of the square and its icon: destructive for a dialog that confirms deleting something.

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
level1 2 3 4 5 62Heading level (role="heading" and aria-level). A dialog starts an outline of its own, so the default, 2, rarely needs changing.

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:alert-dialog-description declares no props; every attribute passes through.

sl:alert-dialog-footer declares no props; every attribute passes through.

AttributeValuesDefaultDescription
variantdefault outline ghost destructive secondary linkoutlineVisual style of the button, as on sl:button.
sizexs sm default lgdefaultSize of the button, as on sl:button.

Every prop can also be set from an expression: th:variant="${...}". Any other attribute passes through to the rendered element, and class is appended.

An alert dialog uses the dialog’s Alpine component, slDialog; see Dialog.

  • Most of it works without Alpine. Opening from a button, closing with Escape or Cancel, and focus on Cancel are the browser’s. Alpine is needed for open and the sl-dialog-close event.
  • It needs no Alpine plugin.
  • Keep your own x-data off <sl:alert-dialog>: it would replace slDialog. Put it on an element inside.