Operational Depth

Operational note

A Theme Toggle Without JavaScript

A practical walkthrough of building a persistent light/dark theme toggle without JavaScript, using only a Laravel route, a cookie, Blade-rendered state, and CSS variables.

Table of Contents

A theme toggle does not need JavaScript

The usual way to build a light/dark theme switch is to reach for JavaScript: read local storage, toggle a class on document.documentElement, update a button label, and hope the initial paint does not flash the wrong theme.

That works, but it is not the only option. If the site is rendered on the server, the server can own the preference. The browser follows a normal link, the app stores a cookie, and every future page render includes the right theme from the start.

My blog now uses that approach. There is no JavaScript involved in the public theme toggle. Or anywhere on the website at all.

The shape of the solution

The implementation has four parts:

  1. A route that accepts the desired theme.
  2. A controller action that stores the theme in a cookie and redirects back.
  3. A Blade layout that reads the cookie and adds data-theme to the <html> element.
  4. CSS variables that change when data-theme="light" is present.

The key idea is that the theme is just server-rendered state. The browser does not need to mutate the page after it loads.

Add a route

The route is deliberately small:

Route::get('/theme/{theme}', [BlogController::class, 'theme'])
    ->whereIn('theme', ['dark', 'light'])
    ->name('blog.theme');

This is a GET route because switching the theme is navigation-friendly progressive enhancement. A plain anchor can trigger it, it works without forms, and it can redirect back to the page the reader was already on.

The whereIn constraint matters. It keeps the route honest: the only valid values are dark and light.

The controller action stores the selected theme for a year, then sends the reader back:

use Illuminate\Http\RedirectResponse;

public function theme(string $theme): RedirectResponse
{
    return redirect()
        ->back(fallback: route('home'))
        ->withCookie(cookie(
            name: 'blog_theme',
            value: $theme,
            minutes: 60 * 24 * 365,
            sameSite: 'lax',
        ));
}

There is no database table, no session requirement, and no client-side storage. A cookie is enough because the preference is tiny and only affects rendering.

The fallback keeps direct visits to /theme/light or missing referrers from turning into a bad redirect.

Render the selected theme in Blade

At the top of the public blog layout, read the cookie and choose a safe default:

@php
    $blogTheme = request()->cookie('blog_theme') === 'light' ? 'light' : 'dark';
    $nextBlogTheme = $blogTheme === 'light' ? 'dark' : 'light';
    $nextBlogThemeSymbol = $nextBlogTheme === 'light' ? '☼' : '☾';
@endphp
<html lang="en" data-theme="{{ $blogTheme }}">

The layout defaults to dark unless the cookie explicitly says light. That mirrors the route constraint and avoids trusting arbitrary cookie values.

The toggle itself is just a link:

<a
    href="{{ route('blog.theme', $nextBlogTheme) }}"
    class="text-term-text-dim hover:text-term-cyan transition-colors"
    aria-label="Switch to {{ $nextBlogTheme }} theme"
>[{{ $nextBlogThemeSymbol }}]</a>

The visible UI stays compact and terminal-like with [☼] and [☾], while the aria-label gives assistive technology the real action: "Switch to light theme" or "Switch to dark theme."

The layout also declares supported color schemes:

<meta name="color-scheme" content="dark light">

That lets the browser know both modes are intentional.

Use CSS variables for the palette

The existing blog theme already used Tailwind v4 theme variables such as --color-term-bg, --color-term-text, and --color-term-cyan.

That made the light theme small. The dark palette remains the default:

@theme {
  --color-term-bg: #0d1117;
  --color-term-bg-alt: #161b22;
  --color-term-surface: #1c2128;
  --color-term-border: #30363d;
  --color-term-text: #c9d1d9;
  --color-term-text-dim: #8b949e;
  --color-term-green: #3fb950;
  --color-term-cyan: #58a6ff;
  --color-term-yellow: #d29922;
  --color-term-magenta: #bc8cff;
  --color-term-red: #f85149;
  --color-term-orange: #d18616;
}

Then the light theme overrides the same variables:

html[data-theme="dark"] {
  color-scheme: dark;
}

html[data-theme="light"] {
  color-scheme: light;
  --color-term-bg: #f6f8fb;
  --color-term-bg-alt: #eaf0f6;
  --color-term-surface: #ffffff;
  --color-term-border: #c7d1dc;
  --color-term-text: #1f2937;
  --color-term-text-dim: #5f6f82;
  --color-term-green: #188038;
  --color-term-cyan: #0969da;
  --color-term-yellow: #8a5a00;
  --color-term-magenta: #8250df;
  --color-term-red: #cf222e;
  --color-term-orange: #9a6700;
}

Because the templates already use classes like bg-term-bg, text-term-text, border-term-border, and hover:text-term-cyan, the rest of the site follows automatically. Posts, code blocks, labels, category badges, the header, and the footer all read from the same palette.

That is the part worth designing for: if your components use semantic color variables, a theme is mostly data.

Test the behavior

The feature tests cover the contract:

public function test_blog_defaults_to_dark_theme_with_light_theme_toggle(): void
{
    $response = $this->get('/');

    $response->assertStatus(200);
    $response->assertSee('data-theme="dark"', false);
    $response->assertSee('[☼]');
    $response->assertSee('aria-label="Switch to light theme"', false);
    $response->assertSee(route('blog.theme', 'light'), false);
    $response->assertDontSee('resources/js/app.js');
}

That checks the default render, the visible icon, the accessible label, the target route, and the absence of the public app JavaScript bundle.

The light-cookie case checks the inverse:

public function test_blog_uses_light_theme_from_cookie_with_dark_theme_toggle(): void
{
    $response = $this->withCookie('blog_theme', 'light')->get('/');

    $response->assertStatus(200);
    $response->assertSee('data-theme="light"', false);
    $response->assertSee('[☾]');
    $response->assertSee('aria-label="Switch to dark theme"', false);
    $response->assertSee(route('blog.theme', 'dark'), false);
}

And the route itself gets a persistence test:

public function test_theme_route_stores_theme_cookie_and_redirects_back(): void
{
    $response = $this->from('/categories')->get('/theme/light');

    $response->assertRedirect('/categories');
    $response->assertCookie('blog_theme', 'light');
}

Why this is a good fit

This pattern is boring in the best way. It works with normal links. It persists across pages. It renders the correct theme before the browser paints the page. It does not require local storage, hydration, Alpine, React, or a tiny inline script in the document head.

There are tradeoffs. Clicking the toggle performs a request and reloads the page. For this blog, that is fine. The state change is rare, the implementation is small, and the resulting markup remains durable.

If the interaction needs to be instant inside a complex application, JavaScript may be worth it. For a server-rendered blog, a route plus a cookie is enough.

Previous Finding a Critical Authorization Flaw in phpVMS by Following the Code