Skip to content

Theme Toggle

Theme toggleAn icon button opening a menu with Light, Dark and System; the icon shows the page's theme.
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).

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.

One buttonas="button" switches between light and dark with one press (aria-pressed is on while 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.

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.

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"/>

The labels are messages, which your messages.properties can reword or translate:

sl.theme-toggle.label=Theme
sl.theme-toggle.dark-mode=Dark mode
sl.theme-toggle.light=Light
sl.theme-toggle.dark=Dark
sl.theme-toggle.system=System

sl.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.

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.

  • More themes than light and dark. The theme script and the skins know the dark class 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.
AttributeValuesDefaultDescription
asmenu buttonmenumenu: 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.
idstringtheme-toggleThe 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.
variantdefault outline ghost destructive secondary linkoutlineVisual style of the button, as on sl:button.
sizeicon icon-xs icon-sm icon-lgiconSize of the button: one of sl:button's icon sizes.
sidetop right bottom leftbottomThe side of the button the menu opens on, as on sl:dropdown-menu-content.
alignstart center endendHow 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.

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 expects dark. Alpine is included by default; see Alpine.
  • Attributes go to the button, class and your own x-data included; 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 dark class 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.