Koala logo Design

Dark mode

The Portal uses class-based dark mode with .dark on the <html> element, persisted to localStorage and a cookie. A circle-reveal animation uses the View Transitions API.

Toggle pattern

The dark mode toggle lives in the navbar's Alpine.js x-data object. It reads the initial state from localStorage, falls back to the cookie, then falls back to the system preference. On toggle, it updates localStorage, the cookie, and the .dark class.

// Navbar x-data initialisation
darkMode: (function() {
    var ls = localStorage.getItem('color-theme');
    if (ls) return ls === 'dark';
    var m = document.cookie.match(/(?:^|;\s*)color-theme=(\w+)/);
    if (m) return m[1] === 'dark';
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
})(),

toggleDarkMode(event) {
    var self = this;
    var apply = function() {
        self.darkMode = !self.darkMode;
        if (self.darkMode) {
            document.documentElement.classList.add('dark');
            localStorage.setItem('color-theme', 'dark');
            document.cookie = 'color-theme=dark;path=/;max-age=31536000;secure;samesite=lax';
        } else {
            document.documentElement.classList.remove('dark');
            localStorage.setItem('color-theme', 'light');
            document.cookie = 'color-theme=light;path=/;max-age=31536000;secure;samesite=lax';
        }
    };

    if (!document.startViewTransition || !event) {
        apply();
        return;
    }

    // Circle reveal animation (see below)
    var btn = event.currentTarget || event.target;
    var rect = btn.getBoundingClientRect();
    var cx = rect.left + rect.width / 2;
    var cy = rect.top + rect.height / 2;
    var maxX = Math.max(cx, window.innerWidth - cx);
    var maxY = Math.max(cy, window.innerHeight - cy);
    var maxRadius = Math.ceil(Math.sqrt(maxX * maxX + maxY * maxY));

    document.documentElement.style.setProperty('--toggle-x', cx + 'px');
    document.documentElement.style.setProperty('--toggle-y', cy + 'px');
    document.documentElement.style.setProperty('--toggle-r', maxRadius + 'px');

    document.startViewTransition(function() { apply(); });
}

Circle reveal animation

When document.startViewTransition is available (Chromium browsers), the dark mode toggle triggers a circle-reveal animation. The animation expands a clip-path: circle() from the toggle button's position outward to fill the viewport. It falls back to an instant switch in unsupported browsers.

/* Assets/app.css - View Transitions API circle reveal */
::view-transition-old(root),
::view-transition-new(root) {
    animation: none;
    mix-blend-mode: normal;
}

::view-transition-new(root) {
    animation: dark-mode-reveal 0.5s ease-in-out;
}

@keyframes dark-mode-reveal {
    from {
        clip-path: circle(0px at var(--toggle-x) var(--toggle-y));
    }
    to {
        clip-path: circle(var(--toggle-r) at var(--toggle-x) var(--toggle-y));
    }
}

Tailwind CSS custom variant

Tailwind CSS v4 uses a custom variant to enable class-based dark mode instead of the default prefers-color-scheme media query. This is defined in Assets/app.css.

/* Assets/app.css */
@custom-variant dark (&:where(.dark, .dark *));

/* This means dark: utilities activate when .dark is on the html element */
/* or on any ancestor element. */

/* Usage: always pair light + dark classes */
<div class="bg-white dark:bg-gray-800">...</div>
<p class="text-gray-900 dark:text-white">...</p>

Flash prevention

A blocking <script> in the <head> applies the .dark class before the first paint to prevent a flash of light mode content.

<!-- In <head> before any CSS/content -->
<script>
    if (localStorage.getItem('color-theme') === 'dark' ||
        (!localStorage.getItem('color-theme') &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark');
    }
</script>

Writing dark mode classes

Every visual property that changes between light and dark mode must have both a light and dark: variant. Never write a light-only background, border, or text colour on interactive or structural elements.

Card in current theme

Secondary text adapts too.

Subtle background variant

Used for sidebars and table headers.

Common light/dark class pairs

Reference table of the most common class pairs used across the app.

Purpose Light class Dark class
Page background bg-[#FAF8F3] dark:bg-gray-900
Card background bg-white dark:bg-gray-800
Input background bg-white dark:bg-gray-700
Primary border border-gray-200 dark:border-gray-700
Input border border-gray-200 dark:border-gray-600
Heading text text-gray-900 dark:text-white
Body text text-gray-900 dark:text-gray-300
Secondary text text-gray-500 dark:text-gray-400
Muted text text-gray-400 dark:text-gray-500
Sidebar / table header bg bg-gray-50 dark:bg-gray-800/50
Hover background hover:bg-gray-100 dark:hover:bg-gray-700
Even row stripe even:bg-gray-50 dark:even:bg-gray-800/50

Chrome extension sync

The Portal supports syncing dark mode with a Chrome extension. When the extension sets a koala-extension-active cookie, the navbar polls the color-theme cookie every 2 seconds and updates the UI if it differs from the current state.

// Navbar x-init — polls cookie for extension changes
setInterval(() => {
    if (!document.cookie.includes('koala-extension-active')) return;
    var m = document.cookie.match(/(?:^|;\s*)color-theme=(\w+)/);
    var cookieTheme = m ? m[1] === 'dark' : null;
    if (cookieTheme !== null && cookieTheme !== darkMode) {
        darkMode = cookieTheme;
        if (darkMode) {
            document.documentElement.classList.add('dark');
            localStorage.setItem('color-theme', 'dark');
        } else {
            document.documentElement.classList.remove('dark');
            localStorage.setItem('color-theme', 'light');
        }
    }
}, 2000);