Theme Toggle
Thymeleaf source
<sl:theme-toggle/>Rendered HTML
<div class="theme-toggle" x-data="slThemeToggle"> <button class="button dropdown-menu-trigger theme-toggle-trigger" type="button" data-variant="outline" data-size="icon" id="theme-toggle-trigger" popovertarget="theme-toggle" aria-haspopup="menu" aria-label="Theme"> <svg xmlns="http://www.w3.org/2000/svg" class="icon theme-toggle-light" 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"> <circle cx="12" cy="12" r="4"></circle> <path d="M12 2v2"></path> <path d="M12 20v2"></path> <path d="m4.93 4.93 1.41 1.41"></path> <path d="m17.66 17.66 1.41 1.41"></path> <path d="M2 12h2"></path> <path d="M20 12h2"></path> <path d="m6.34 17.66-1.41 1.41"></path> <path d="m19.07 4.93-1.41 1.41"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon theme-toggle-dark" 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="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"></path> </svg> </button> <div class="dropdown-menu-content" id="theme-toggle" role="menu" popover tabindex="-1" x-data="slDropdownMenu" aria-labelledby="theme-toggle-trigger" data-align="end"> <div class="dropdown-menu-radio-group" role="group"> <button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" data-theme-choice="light"> <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"> <circle cx="12" cy="12" r="4"></circle> <path d="M12 2v2"></path> <path d="M12 20v2"></path> <path d="m4.93 4.93 1.41 1.41"></path> <path d="m17.66 17.66 1.41 1.41"></path> <path d="M2 12h2"></path> <path d="M20 12h2"></path> <path d="m6.34 17.66-1.41 1.41"></path> <path d="m19.07 4.93-1.41 1.41"></path> </svg> Light <span class="dropdown-menu-item-indicator"> <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="M20 6 9 17l-5-5"></path> </svg> </span> </button> <button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" data-theme-choice="dark"> <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="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"></path> </svg> Dark <span class="dropdown-menu-item-indicator"> <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="M20 6 9 17l-5-5"></path> </svg> </span> </button> <button type="button" class="dropdown-menu-radio-item" role="menuitemradio" aria-checked="false" data-theme-choice="system"> <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"> <rect width="20" height="14" x="2" y="3" rx="2"></rect> <line x1="8" x2="16" y1="21" y2="21"></line> <line x1="12" x2="12" y1="17" y2="21"></line> </svg> System <span class="dropdown-menu-item-indicator"> <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="M20 6 9 17l-5-5"></path> </svg> </span> </button> </div> </div></div>Put the theme script in the <head> of your layout, as Getting started shows, and the
toggle wherever it belongs, usually the page header:
<head> <th:block th:replace="~{sl/layout :: theme-script}"></th:block> <th:block th:replace="~{sl/layout :: assets}"></th:block></head><body><header> ... <sl:theme-toggle/></header>The toggle is an icon button that opens a menu with Light, Dark and System. Light and Dark are stored in
the browser (localStorage, key shadleaf-theme); System removes the key, and the page follows the operating system
again. The theme script reads that key before the first paint, so every page after the choice starts in the right
theme, with no flash of the other one.
The button shows a sun in the light theme and a moon in the dark one. A screen reader hears something like “Theme, menu button”; in the menu, “Light, checked, menu item radio, 1 of 3” (the wording differs between screen readers).
One button
Section titled “One button”as="button" makes it one button that switches between light and dark. It is a toggle button for dark mode: a screen
reader hears “Dark mode, toggle button, pressed” while the page is dark.
Thymeleaf source
<sl:theme-toggle as="button" variant="ghost"/>Rendered HTML
<div class="theme-toggle" x-data="slThemeToggle"> <button class="button theme-toggle-trigger theme-toggle-button" type="button" data-variant="ghost" data-size="icon" aria-label="Dark mode"> <svg xmlns="http://www.w3.org/2000/svg" class="icon theme-toggle-light" 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"> <circle cx="12" cy="12" r="4"></circle> <path d="M12 2v2"></path> <path d="M12 20v2"></path> <path d="m4.93 4.93 1.41 1.41"></path> <path d="m17.66 17.66 1.41 1.41"></path> <path d="M2 12h2"></path> <path d="M20 12h2"></path> <path d="m6.34 17.66-1.41 1.41"></path> <path d="m19.07 4.93-1.41 1.41"></path> </svg> <svg xmlns="http://www.w3.org/2000/svg" class="icon theme-toggle-dark" 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="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"></path> </svg> </button></div>Pressing it stores light or dark, so once pressed the page no longer follows the operating system. Use the menu where that matters.
What the server renders
Section titled “What the server renders”The server cannot know what the browser stored, so neither the menu’s check mark nor the button’s pressed state is in
the markup: the toggle’s Alpine component sets them when it starts. Nothing shows wrong in the meantime. The icon comes
from the dark class the theme script put on <html> before the first paint, and the menu is closed until someone
opens it. A toggle that htmx swaps in, or brings back from its history cache, works the same way.
Several toggles, several tabs
Section titled “Several toggles, several tabs”Every toggle on the page shows the same state, and a choice made in another tab of the same site changes this page
too. While the choice is System, the page follows the operating system when it switches between light and dark (at
sunset, say). Each toggle on a page needs its own id when it is a menu:
<sl:theme-toggle id="header-theme" variant="ghost"/>...<sl:theme-toggle id="footer-theme" side="top"/>Labels
Section titled “Labels”The labels are messages, which your messages.properties can reword or translate:
sl.theme-toggle.label=Themesl.theme-toggle.dark-mode=Dark modesl.theme-toggle.light=Lightsl.theme-toggle.dark=Darksl.theme-toggle.system=Systemsl.theme-toggle.label names the menu’s button and sl.theme-toggle.dark-mode the single button; an aria-label on
<sl:theme-toggle> replaces either for one toggle.
There is no tooltip. The sun and the moon are what people look for, and a tooltip would only repeat the button’s name to a screen reader, as its description.
Without JavaScript
Section titled “Without JavaScript”Nothing can store a choice without JavaScript, and the theme script cannot run either, so the page stays light. The
toggle is then not shown at all (@media (scripting: none)), rather than shown and doing nothing.
Left out
Section titled “Left out”- More themes than light and dark. The theme script and the skins know the
darkclass only. - A choice stored on the server (a user setting). Render
class="dark"on<html>yourself and leave out the theme script and the toggle; the toggle stores in the browser only. - The keyboard shortcut shadcn’s own site has (D). Your application can add one that clicks the toggle.
Attributes
Section titled “Attributes”| Attribute | Values | Default | Description |
|---|---|---|---|
as | menu button | menu | menu: a menu with Light, Dark and System (menuitemradios). button: one toggle button for dark mode (aria-pressed), which cannot go back to following the system. |
id | string | theme-toggle | The menu's id (its button gets the id with -trigger added). Two menus on a page need different ids. With as="button" it is the button's id, rendered only when set. |
variant | default outline ghost destructive secondary link | outline | Visual style of the button, as on sl:button. |
size | icon icon-xs icon-sm icon-lg | icon | Size of the button: one of sl:button's icon sizes. |
side | top right bottom left | bottom | The side of the button the menu opens on, as on sl:dropdown-menu-content. |
align | start center end | end | How the menu lines up with the button along that side, as on sl:dropdown-menu-content: end lines up the end edges. |
Every prop can also be set from an expression: th:as="${...}". Any other attribute passes through to the rendered element, and class is appended.
JavaScript
Section titled “JavaScript”A small Alpine component, slThemeToggle, does the work: <sl:theme-toggle> renders as
<div class="theme-toggle" x-data="slThemeToggle">, around a dropdown menu (whose own
slDropdownMenu handles the keyboard) or a button.
- It needs Alpine, and no Alpine plugin: it writes the key itself, not with
$persist, which would store"dark"as JSON, quotes included, where the theme script expectsdark. Alpine is included by default; see Alpine. - Attributes go to the button,
classand your ownx-dataincluded; the wrapper around it has no box. - It changes nothing when it starts. The page is already in the stored theme; a page that sets the
darkclass some other way keeps it until someone chooses.
| Event | On | Effect |
|---|---|---|
sl-theme-change |
the document | Fired after the theme changed: by a toggle, in another tab, or by the operating system while the choice is System. The detail is {theme: 'light' | 'dark' | 'system', dark: true | false}. For a chart that has to redraw, say. |