Redesign theme toggle as a two-icon light/dark switch

Replace the single-icon popover with a dropdown of radio inputs with a
compact two-button toggle (light/dark), inspired by vale.rocks' design:
clicking an icon forces that theme, clicking the active icon again
returns to following the system preference. Keeps the existing
light-dark() colour-scheme mechanism and view-transition wipe
animation, and reuses the project's existing Fluent UI sun/moon icons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn4aNjE3ofpDM78F4qmdCV
This commit is contained in:
Jonny Barnes 2026-08-22 18:14:08 +01:00
commit e9d8f4c74b
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
7 changed files with 102 additions and 136 deletions

View file

@ -1,72 +1,68 @@
@layer components {
#theme-selector {
display: flex;
justify-self: start;
align-items: center;
gap: .25rem;
padding: .25rem;
border-radius: 999px;
background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);
button {
display: grid;
place-items: center;
appearance: none;
border: none;
background: none;
border-radius: 999px;
padding: .375rem;
background: transparent;
color: inherit;
transition: background-color .2s, color .2s;
&:hover {
cursor: pointer;
}
}
/* This is the element with the [popover] attribute */
#theme-selector-dropdown {
position: absolute;
position-area: block-end span-inline-start;
margin: 0;
border: 2px solid var(--clr-border);
background-color: var(--clr-background);
color: var(--clr-text);
fieldset {
border: 0;
display: flex;
flex-direction: row;
gap: 1ch;
input {
display: none;
}
&:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
}
}
/*
* This is the element with the [popover] attribute
* Here we are styling the open and closing animations
*/
@media (prefers-reduced-motion: no-preference) {
#theme-selector-dropdown {
&:popover-open {
transform: translateY(0) scale(1);
opacity: 1;
svg {
width: 1.5rem;
height: 1.5rem;
fill: currentcolor;
}
/*
* Start styles for the opening transition.
* Added to :popover-open, but after the opened styles.
*/
@starting-style {
transform: translateY(30px) scale(0);
opacity: 0;
}
@media (hover: hover) {
&:hover {
background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);
}
/*
* End styles for the closing transition.
*/
transform: translateY(0) scale(0);
opacity: 0;
&[aria-pressed="true"]:hover {
background-color: var(--clr-text);
}
}
/*
* Enumerate transitioning properties, including display and overlay.
*/
transition: transform, opacity, display allow-discrete, overlay allow-discrete;
transition-duration: 0.5s;
transform-origin: top right;
&[aria-pressed="true"] {
background-color: var(--clr-text);
color: var(--clr-background);
}
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
::view-transition-group(changing-theme) {
animation-duration: 1.25s;
}

View file

@ -1,69 +1,60 @@
class ThemeSelector {
constructor() {
this.widget = document.querySelector('#theme-selector');
this.btnLight = this.widget.querySelector('#btn-light');
this.btnDark = this.widget.querySelector('#btn-dark');
this.status = this.widget.querySelector('#theme-status');
this.currentTheme = 'system';
}
setupEventListeners() {
this.widget.querySelectorAll('#theme-selector-dropdown input').forEach((radioInput) => radioInput.addEventListener('input', (e) => {
let theme = e.target.value;
this.btnLight.addEventListener('click', () => {
this.applyTheme(this.currentTheme === 'light' ? 'system' : 'light');
});
// Update current icon
this.widget.querySelectorAll('.toggle svg').forEach((svg) => {
if (svg.classList.contains(theme)) {
svg.style.display = '';
} else {
svg.style.display = 'none';
}
});
this.btnDark.addEventListener('click', () => {
this.applyTheme(this.currentTheme === 'dark' ? 'system' : 'dark');
});
}
// Set the theme
let selectedTheme;
switch (theme) {
case 'dark':
case 'light':
selectedTheme = theme;
break;
default:
selectedTheme = 'light dark';
}
applyTheme(theme) {
const selectedTheme = (theme === 'light' || theme === 'dark') ? theme : 'light dark';
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.querySelector('html');
let currentTheme = html.style.getPropertyValue('color-scheme');
if (currentTheme === '') {
currentTheme = 'light dark';
}
const systemTheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
const html = document.documentElement;
let currentColorScheme = html.style.getPropertyValue('color-scheme');
if (currentColorScheme === '') {
currentColorScheme = 'light dark';
}
/* do we need to transition */
let doTransition = false;
/* do we need to transition */
let doTransition = false;
if (selectedTheme !== currentTheme) {
if (selectedTheme === 'light dark') {
doTransition = currentTheme !== systemTheme;
} else if (currentTheme === 'light dark') {
doTransition = selectedTheme !== systemTheme;
} else {
doTransition = true;
}
}
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(() => {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
});
if (selectedTheme !== currentColorScheme) {
if (selectedTheme === 'light dark') {
doTransition = currentColorScheme !== systemTheme;
} else if (currentColorScheme === 'light dark') {
doTransition = selectedTheme !== systemTheme;
} else {
// Close the popover
document.querySelector('#theme-selector-dropdown').togglePopover();
// Set the colour theme
html.style.setProperty('color-scheme', selectedTheme);
doTransition = true;
}
}));
}
const applyChange = () => {
this.currentTheme = theme;
html.style.setProperty('color-scheme', selectedTheme);
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
};
html.style.viewTransitionName = 'changing-theme';
if (doTransition && document.startViewTransition) {
document.startViewTransition(applyChange);
} else {
applyChange();
}
}
}

View file

@ -38,35 +38,14 @@
<a href="/projects">Projects</a>
<a href="{{ route('feed.notes.json') }}" class="rss-icon">@include('icons.json-feed', ['title' => 'JSON Feed'])</a>
</nav>
<div id="theme-selector">
<button class="toggle" popovertarget="theme-selector-dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="system" style=""><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="dark" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" class="light" style="display: none"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
<div id="theme-selector" role="region" aria-label="Theme switcher">
<button id="btn-light" aria-label="Light mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</button>
<div id="theme-selector-dropdown" popover>
<fieldset>
<legend>Select theme:</legend>
<div>
<input type="radio" id="theme-system" name="theme" value="system" checked>
<label for="theme-system">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m0-1.5v-17a8.5 8.5 0 0 1 0 17"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-dark" name="theme" value="dark">
<label for="theme-dark">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</label>
</div>
<div>
<input type="radio" id="theme-light" name="theme" value="light">
<label for="theme-light">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2m5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0m4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19m-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06m1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06m-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06z"/></svg>
</label>
</div>
</fieldset>
</div>
<button id="btn-dark" aria-label="Dark mode" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><!-- Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE --><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A10 10 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.233-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.96 9.96 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662"/></svg>
</button>
<span id="theme-status" class="sr-only" aria-live="polite"></span>
</div>
</header>