Theme Switching

Implement dynamic theme switching with localStorage persistence.

Theme Switching

Learn how to implement theme switching that persists across page loads.

Choosing your theme catalog

Turbo Themes ships with 30 themes across 10 vendor families. A “catalog” is simply the Turbo Themes ships with 27 themes across 10 vendor families. A “catalog” is simply the subset of themes you expose to users in your app. The right approach depends on your build tooling.

Approach A — hardcoded list (no build step required)

The simplest pattern. Maintain a plain array and update it when you upgrade the package:

// Minimal set — copy-paste friendly
const CATALOG = [
  'catppuccin-mocha',
  'catppuccin-latte',
  'dracula',
  'github-dark',
  'github-light',
];

Approach B — filter by vendor or appearance (TS/ESM build)

The @lgtm-hq/turbo-themes package exports getThemesByVendor() and getThemesByAppearance() so your catalog stays in sync with the package automatically:

import {
  themeIds,
  getThemesByVendor,
  getThemesByAppearance,
} from '@lgtm-hq/turbo-themes';

// Opt-in to specific vendors
const catppuccin = getThemesByVendor('catppuccin').map((f) => f.id);
// → ['catppuccin-mocha', 'catppuccin-macchiato', 'catppuccin-frappe', 'catppuccin-latte']

// Dark-only themes
const darkOnly = getThemesByAppearance('dark').map((f) => f.id);

// Light-only themes
const lightOnly = getThemesByAppearance('light').map((f) => f.id);

// All themes
const allThemes = themeIds; // readonly string[], 24 entries

Planned (#495): A themeSets object (pre-defined subsets like themeSets.minimal, themeSets.dark) and a createThemeCatalog() factory (filter by vendor and appearance in one call) will ship in #495 and make these patterns even more concise.

Basic Implementation

1. Set Up HTML

Give your theme CSS link an ID so JavaScript can swap it:

<head>
  <!-- Core styles -->
  <link rel="stylesheet" href="/css/turbo-core.css" />
  <link rel="stylesheet" href="/css/turbo-base.css" />

  <!-- Theme (with ID for JavaScript access) -->
  <link
    id="turbo-theme-css"
    rel="stylesheet"
    href="/css/themes/turbo/catppuccin-mocha.css"
  />
</head>

2. Create Theme Switcher

const CATALOG = [
  'catppuccin-mocha',
  'catppuccin-latte',
  'dracula',
  'github-dark',
  'github-light',
];

function setTheme(themeName) {
  if (!CATALOG.includes(themeName)) return;

  document.getElementById('turbo-theme-css').href =
    `/css/themes/turbo/${themeName}.css`;
  document.documentElement.setAttribute('data-theme', themeName);
  localStorage.setItem('turbo-theme', themeName);
}

3. Load Saved Theme

document.addEventListener('DOMContentLoaded', () => {
  const saved = localStorage.getItem('turbo-theme');
  const initial = CATALOG.includes(saved) ? saved : 'catppuccin-mocha';
  setTheme(initial);
});

Preventing FOUC

Flash of Unstyled Content (FOUC) happens when the page renders with the wrong theme before JavaScript runs. A blocking script in <head> applies the saved theme synchronously before first paint.

Option 1 — Use generateBlockingScript()

The @lgtm-hq/turbo-theme-selector package generates a self-contained IIFE from your catalog. Run it at build time and inline the result:

import { generateBlockingScript } from '@lgtm-hq/turbo-theme-selector';
import { themeIds } from '@lgtm-hq/turbo-themes';

// Derive validThemes from your curated catalog (or pass themeIds for all):
const validThemes = [
  'catppuccin-mocha',
  'catppuccin-latte',
  'dracula',
  'github-dark',
  'github-light',
];
const blockingScript = generateBlockingScript({ validThemes });
// → embed blockingScript in a <script> tag in <head>

Astro example:

---
import { generateBlockingScript } from '@lgtm-hq/turbo-theme-selector';
const blockingScript = generateBlockingScript();
---
<head>
  <Fragment set:html={`<script>${blockingScript}</script>`} />
</head>

Option 2 — Inline script (no build step)

For plain HTML files, write the script directly using your hardcoded catalog:

<script>
  (function () {
    try {
      var DEFAULT_THEME = 'catppuccin-mocha';
      var VALID_THEMES = [
        'bulma-dark',
        'bulma-light',
        'catppuccin-frappe',
        'catppuccin-latte',
        'catppuccin-macchiato',
        'catppuccin-mocha',
        'dracula',
        'everforest-dark-hard',
        'everforest-dark',
        'everforest-dark-soft',
        'everforest-light-hard',
        'everforest-light',
        'everforest-light-soft',
        'github-dark',
        'github-light',
        'gruvbox-dark-hard',
        'gruvbox-dark-soft',
        'gruvbox-dark',
        'gruvbox-light-hard',
        'gruvbox-light-soft',
        'gruvbox-light',
        'nord',
        'rose-pine-dawn',
        'rose-pine-moon',
        'rose-pine',
        'solarized-dark',
        'solarized-light',
        'tokyo-night-dark',
        'tokyo-night-light',
        'tokyo-night-storm',
        'kanagawa-wave',
        'kanagawa-dragon',
        'kanagawa-lotus',
        'ayu-dark',
        'ayu-mirage',
        'ayu-light',
      ];

      var saved = localStorage.getItem('turbo-theme');
      var theme = saved && VALID_THEMES.indexOf(saved) !== -1 ? saved : DEFAULT_THEME;

      document.documentElement.setAttribute('data-theme', theme);
      var link = document.getElementById('turbo-theme-css');
      if (link) link.href = '/css/themes/turbo/' + theme + '.css';
    } catch (e) {
      console.warn('Unable to load saved theme:', e);
    }
  })();
</script>

Keep VALID_THEMES in sync with your <select> options. Trim the list to only the themes your app supports.

Theme Selector UI

<select id="theme-select" onchange="setTheme(this.value)">
  <optgroup label="Catppuccin">
    <option value="catppuccin-mocha">Catppuccin Mocha</option>
    <option value="catppuccin-latte">Catppuccin Latte</option>
  </optgroup>
  <optgroup label="GitHub">
    <option value="github-dark">GitHub Dark</option>
    <option value="github-light">GitHub Light</option>
  </optgroup>
  <optgroup label="Dracula">
    <option value="dracula">Dracula</option>
  </optgroup>
</select>

<script>
  document.addEventListener('DOMContentLoaded', function () {
    var saved = localStorage.getItem('turbo-theme') || 'catppuccin-mocha';
    document.getElementById('theme-select').value = saved;
  });
</script>

When you have a build step, generate the <option> elements from your catalog:

import {
  themeIds,
  flavors,
  getThemesByVendor,
  VENDOR_GROUPS,
} from '@lgtm-hq/turbo-themes';

const flavorMap = new Map(flavors.map((f) => [f.id, f]));

function populateSelector(select: HTMLSelectElement, catalog: readonly string[]): void {
  select.innerHTML = '';
  const groups = new Map<string, HTMLOptGroupElement>();

  for (const id of catalog) {
    const flavor = flavorMap.get(id);
    if (!flavor) continue;

    let group = groups.get(flavor.vendor);
    if (!group) {
      group = document.createElement('optgroup');
      group.label = flavor.vendor;
      select.appendChild(group);
      groups.set(flavor.vendor, group);
    }

    const opt = document.createElement('option');
    opt.value = id;
    opt.textContent = flavor.label;
    group.appendChild(opt);
  }
}

Light/Dark Toggle

const DARK_THEMES = [
  'catppuccin-mocha',
  'catppuccin-macchiato',
  'catppuccin-frappe',
  'dracula',
  'github-dark',
];
const LIGHT_THEMES = ['catppuccin-latte', 'github-light'];

// Or derive from getThemesByAppearance():
// import { getThemesByAppearance } from '@lgtm-hq/turbo-themes';
// const DARK_THEMES = getThemesByAppearance('dark').map(f => f.id);

function toggleTheme() {
  const current = localStorage.getItem('turbo-theme') || 'catppuccin-mocha';
  const isDark = DARK_THEMES.includes(current);
  setTheme(isDark ? 'catppuccin-latte' : 'catppuccin-mocha');
}

System Preference Detection

function getDefaultTheme() {
  const saved = localStorage.getItem('turbo-theme');
  if (saved && CATALOG.includes(saved)) return saved;

  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'catppuccin-mocha'
    : 'catppuccin-latte';
}

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('turbo-theme')) {
    setTheme(e.matches ? 'catppuccin-mocha' : 'catppuccin-latte');
  }
});

Next Steps