How to make a light/dark selector and save preference in JS in WordPress

Contents

Overview

This tutorial shows, in full detail, how to add a light/dark theme selector to a WordPress site and persist the users preference using JavaScript. It covers:

  • CSS architecture (custom properties and selector strategy).
  • Accessible toggle markup you drop into your theme.
  • Robust JavaScript to detect system preference, apply the theme immediately to avoid flash, let the user toggle, save the preference (localStorage with safe fallbacks), and sync across tabs.
  • WordPress integration: where to put scripts and styles, how to print the early inline script to avoid flash of unstyled content (FOUC), and optional server-side persistence for logged-in users via the REST API and user_meta.
  • Accessibility, performance, testing, caching, and edge cases.

Design choices and recommended approach

Pick one place to apply the theme state: either a data attribute on the root element (document.documentElement, commonly lthtmlgt) or a class on ltbodygt. This tutorial uses data-theme on lthtmlgt because it is available before ltbodygt parses and can be set very early to avoid FOUC.

For saving preferences we use localStorage for anonymous visitors because it is simple and fast. For logged-in users we show an optional REST API example to store the preference in user_meta so the preference follows the user across devices.

CSS: variables and theme selectors

Define color tokens with CSS custom properties in :root for the light default, then override for dark using [data-theme=dark] on the html element. Keep the colors tokens minimal and build the rest of the theme from tokens.

:root{
  / Base tokens (light mode defaults) /
  --bg: #ffffff
  --text: #111827
  --muted: #6b7280
  --accent: #2563eb
  --surface: #f9fafb
  / Add any more tokens for your design system /
}

/ Dark theme overrides /
html[data-theme=dark]{
  --bg: #0b1220
  --text: #e6eef8
  --muted: #a0aec0
  --accent: #60a5fa
  --surface: #071022
}

/ Example usage /
body{
  background-color: var(--bg)
  color: var(--text)
  transition: background-color .18s ease, color .18s ease
}

.header, .card{
  background-color: var(--surface)
  color: var(--text)
}

/ Provide a prefers-color-scheme fallback for users without JS if you want /
@media (prefers-color-scheme: dark){
  :root{
    / Optionally set dark token defaults for UA that respects prefers-color-scheme /
  }
}

Accessible toggle markup

Place a toggle button in your theme (header, navigation, footer). Make it accessible — keyboard focusable, labeled, with an aria-pressed state. The button only needs a simple markup the JavaScript updates its attributes and visual state.



Replace the emoji with inline SVG icons in production if you want crisp icons. Keep the id or a data attribute to select the element in JS.

JavaScript: logic and best practices

The JS does several things:

  1. Apply the saved theme (or system preference) as early as possible to avoid FOUC.
  2. Initialize the toggle UI state (aria-pressed, label, icon).
  3. Handle toggle clicks to switch theme and persist the choice.
  4. Listen to the storage event to sync theme across tabs.
  5. Use try/catch around localStorage because it may be blocked in some privacy modes.

Core JS file (to enqueue in WordPress):

/ theme-toggle.js - core logic /
(function(){
  const STORAGE_KEY = site-theme
  const THEME_LIGHT = light
  const THEME_DARK = dark
  const ALLOWED = [THEME_LIGHT, THEME_DARK]

  / Safe localStorage access /
  function safeGet(key){
    try{
      return window.localStorage ? localStorage.getItem(key) : null
    }catch(e){
      return null
    }
  }
  function safeSet(key, value){
    try{
      if(window.localStorage) localStorage.setItem(key, value)
    }catch(e){
      // ignore
    }
  }
  function safeRemove(key){
    try{
      if(window.localStorage) localStorage.removeItem(key)
    }catch(e){}
  }

  / Detect system preference /
  function prefersDark(){
    return window.matchMedia  window.matchMedia((prefers-color-scheme: dark)).matches
  }

  / Apply theme to html[data-theme] /
  function applyTheme(theme){
    if(!theme  ALLOWED.indexOf(theme) === -1) return
    document.documentElement.setAttribute(data-theme, theme)
  }

  / Determine initial theme (called after DOM ready if not using inline early script) /
  function getInitialTheme(){
    const stored = safeGet(STORAGE_KEY)
    if(stored  ALLOWED.indexOf(stored) !== -1) return stored
    return prefersDark() ? THEME_DARK : THEME_LIGHT
  }

  / Toggle helper /
  function toggleTheme(){
    const current = document.documentElement.getAttribute(data-theme) === THEME_DARK ? THEME_DARK : THEME_LIGHT
    const next = current === THEME_DARK ? THEME_LIGHT : THEME_DARK
    applyTheme(next)
    safeSet(STORAGE_KEY, next)
    updateToggleUI(next)
    maybePersistToServer(next) // optional: persist for logged-in users
  }

  / Update the button UI /
  function updateToggleUI(theme){
    const btn = document.getElementById(theme-toggle)
    if(!btn) return
    const isDark = theme === THEME_DARK
    btn.setAttribute(aria-pressed, String(isDark))
    btn.setAttribute(aria-label, isDark ? Activate light mode : Activate dark mode)
    btn.classList.toggle(is-dark, isDark)
    // update inner icon/label if you have elements inside
  }

  / Cross-tab sync /
  function onStorage(e){
    if(!e) return
    if(e.key === STORAGE_KEY){
      const newVal = e.newValue
      if(newVal  ALLOWED.indexOf(newVal) !== -1){
        applyTheme(newVal)
        updateToggleUI(newVal)
      }else if(newVal === null){
        // removed - fall back to system or default
        applyTheme(prefersDark() ? THEME_DARK : THEME_LIGHT)
      }
    }
  }

  / Optionally save to server for logged-in users:
     Implement maybePersistToServer() to POST the new choice to a WP REST endpoint.
     We call it here, but the function can be a noop in the anonymous version. /
  function maybePersistToServer(theme){
    if(!window.wp  !window.wpThemeSave  !window.wpThemeSave.restNonce) return
    // wpThemeSave object is described in the WordPress integration section
    fetch(window.wpThemeSave.endpoint, {
      method: POST,
      headers: {
        Content-Type: application/json,
        X-WP-Nonce: window.wpThemeSave.restNonce
      },
      body: JSON.stringify({ theme: theme })
    }).catch(function(){ / silent fail / })
  }

  / Init after DOM ready /
  function init(){
    const initial = getInitialTheme()
    applyTheme(initial)
    updateToggleUI(initial)

    const btn = document.getElementById(theme-toggle)
    if(btn){
      btn.addEventListener(click, function(e){
        e.preventDefault()
        toggleTheme()
      })
    }

    / update on system preference changes if no explicit choice stored /
    if(window.matchMedia){
      const mq = window.matchMedia((prefers-color-scheme: dark))
      mq.addEventListener(change, function(e){
        const stored = safeGet(STORAGE_KEY)
        if(!stored){
          applyTheme(e.matches ? THEME_DARK : THEME_LIGHT)
          updateToggleUI(e.matches ? THEME_DARK : THEME_LIGHT)
        }
      })
    }

    window.addEventListener(storage, onStorage, false)
  }

  / Wait for DOMContentLoaded /
  if(document.readyState === loading){
    document.addEventListener(DOMContentLoaded, init)
  }else{
    init()
  }

  / Expose helpers for debugging (optional) /
  window.__siteTheme = {
    set: function(t){ applyTheme(t) safeSet(STORAGE_KEY, t) updateToggleUI(t) },
    get: function(){ return document.documentElement.getAttribute(data-theme) },
    remove: function(){ safeRemove(STORAGE_KEY) }
  }
})()

Important: set theme as early as possible to avoid FOUC

You must apply the selected theme before the browser paints. In WordPress you can do this by outputting a tiny inline script into the document head that reads localStorage (or user metadata printed by PHP) and sets document.documentElement.setAttribute(data-theme, …). This script must run before the style sheets load. See the WordPress integration section for a secure way to output it.

/ inline-in-head.js - small script string to output in ltheadgt /
(function(){
  try{
    var theme = null
    if(window.localStorage){
      theme = localStorage.getItem(site-theme)
    }
    if(!theme){
      // fallback to system preference
      var m = window.matchMedia  window.matchMedia((prefers-color-scheme: dark))
      theme = (m  m.matches) ? dark : light
    }
    if(theme){
      document.documentElement.setAttribute(data-theme, theme)
    }
  }catch(e){ / safest no-op / }
})()

WordPress integration (functions.php examples)

You need three pieces: enqueue your stylesheet(s), enqueue your main JS file(s), and print the early inline script in head to avoid FOUC. Below are examples you can copy into your themes functions.php. The example also shows how to optionally expose a REST endpoint a JS object for saving preference server-side for logged-in users.

/ functions.php - theme integration /

/ 1) Enqueue styles  scripts /
function mytheme_enqueue_theme_scripts(){
  // Main stylesheet
  wp_enqueue_style( mytheme-style, get_stylesheet_uri(), array(), filemtime( get_stylesheet_directory() . /style.css ) )

  // Register main theme toggle script
  wp_register_script( mytheme-theme-toggle, get_template_directory_uri() . /js/theme-toggle.js, array(), 1.0, true )

  // Localize minimal data if you want to expose endpoint   nonce for logged-in users
  if( is_user_logged_in() ){
    nonce = wp_create_nonce( wp_rest )
    data = array(
      endpoint => esc_url_raw( rest_url( mytheme/v1/theme ) ),
      restNonce => nonce,
    )
    wp_localize_script( mytheme-theme-toggle, wpThemeSave, data )
  }

  wp_enqueue_script( mytheme-theme-toggle )
}
add_action( wp_enqueue_scripts, mytheme_enqueue_theme_scripts )


/ 2) Print the tiny inline script in HEAD to set data-theme early.
   Using wp_head with a very high priority ensures this prints before stylesheets render. /
function mytheme_print_early_theme_script(){
  // If user is logged in and you have a saved preference in user meta, prefer server-side value:
  server_theme = 
  if( is_user_logged_in() ){
    user_id = get_current_user_id()
    tm = get_user_meta( user_id, preferred_theme, true )
    if( in_array( tm, array( light, dark ), true ) ){
      server_theme = tm
    }
  }

  // Escape and print a minimal inline script (no line breaks required, keep it small)
  if( server_theme ){
    // Print server-determined theme immediately
    echo 
  }else{
    // Client-side localStorage/system fallback (small)
    ?>
    
     POST,
    callback => mytheme_save_user_theme,
    permission_callback => function( request ){
      return is_user_logged_in()
    }
  ) )
} )

function mytheme_save_user_theme( WP_REST_Request request ){
  params = request->get_json_params()
  if( empty( params[theme] ) ) {
    return new WP_Error( no_theme, No theme provided, array( status => 400 ) )
  }
  theme = params[theme] === dark ? dark : light
  user_id = get_current_user_id()
  update_user_meta( user_id, preferred_theme, theme )
  return array( success => true, theme => theme )
}

Notes on the functions.php approach

  • Printing the tiny script directly in wp_head is the simplest and most robust way to avoid FOUC. You must keep it minimal and careful about XSS: only output static allowed values (light / dark) or values you properly escape.
  • Using wp_add_inline_script() attaches a script relative to an enqueued script. If you need the inline script to run before any CSS paints, using wp_head with priority 1 is the clear method.

Server-side persistence and restoring preference on page load

If you save the preference in user meta (for logged-in users), output that value in the early head script (as shown above) so the theme is set on the server render. This avoids a flash where the site first displays in the default theme and then switches when JS runs.

Accessibility

  • Keyboard: the toggle is a native ltbuttongt, so keyboard activation and focus are automatic.
  • Aria: update aria-pressed and aria-label to reflect the current state (see JS updateToggleUI).
  • Reduced motion: honor prefers-reduced-motion by reducing or disabling transitions if the user requests it.
/ Respect reduced motion /
@media (prefers-reduced-motion: reduce){
  {
    transition: none !important
  }
}

Syncing across tabs and mobile pages

Use the storage event to listen for other tabs changing the theme. The example JS above sets window.addEventListener(storage, onStorage). This keeps multiple open tabs in the same browser in sync. For synchronization across devices, save the preference on the server (REST) for logged-in users.

Privacy modes and localStorage blocked

Access to localStorage can throw errors in some privacy or strict modes. Wrap all localStorage operations in try/catch and fall back to not persisting (the site will still toggle during the session). The example JS uses safeGet/safeSet wrappers.

Testing and debugging checklist

  1. Test at least these scenarios: first visit (no saved pref), saved pref (localStorage), system preference set to dark, system preference changes while on the page, cross-tab sync, private/incognito mode.
  2. Test with JavaScript disabled: ensure the site still looks acceptable (you could implement a CSS fallback using prefers-color-scheme media query if desired).
  3. Test logged-in user flow: save preference, refresh in another browser to confirm server-persisted preference is applied when output by PHP.
  4. Use DevTools to check that the inline script in the head runs before CSS renders (look at Network and Disable cache to emulate fresh load).

Performance and caching considerations

  • Keep the early inline script extremely small (a few lines) to avoid adding significant HTML payload.
  • If your site is served by a full-page cache (Varnish, CDN), server-side user-specific theme embedding is only possible for logged-in users (who bypass full-page caches). For anonymous visitors, localStorage-based switching is the way to go.
  • Avoid large synchronous operations in the head script — only read localStorage and set the attribute.

Alternative persistence strategies

  • Cookies: if you need server-readable values for anonymous users (rare because cached pages wont vary), you can write a short-lived cookie from JS and have the server vary pages by cookie — this complicates caching.
  • Server-side rendering per-user: only realistic for authenticated users where caching can be per-user or bypassed.
  • Local indexDB or similar: overkill for a simple theme preference.

Complete example file layout

Suggested file layout for a theme:

/wp-content/themes/your-theme/style.css contains theme tokens and usage CSS
/wp-content/themes/your-theme/js/theme-toggle.js contains the main JS module shown above
functions.php enqueues assets and prints the early inline script optionally registers REST route
header.php place the toggle button HTML somewhere in the header/nav template

Full minimal end-to-end example (HTML JS CSS snippets)

The snippets below are the concise pieces that together implement the feature. Insert the CSS into your main stylesheet, the inline script in head via functions.php, the main JS in a file enqueued in footer, and the button where appropriate in templates.

Inline early script printed in head by functions.php:

(function(){
  try{
    var t = null
    if(window.localStorage){ t = localStorage.getItem(site-theme) }
    if(!t){
      var m = window.matchMedia  window.matchMedia((prefers-color-scheme: dark))
      t = (m  m.matches) ? dark : light
    }
    if(t){
      document.documentElement.setAttribute(data-theme, t)
    }
  }catch(e){}
})()

Theme toggle button (place in header.php):


Main JS (theme-toggle.js) — included earlier — attach this file with wp_enqueue_script in functions.php.

Minimal CSS tokens (put in style.css):

:root{ --bg:#fff --text:#111 --surface:#f9fafb }
html[data-theme=dark]{ --bg:#071022 --text:#e6eef8 --surface:#071a2a }
body{ background:var(--bg) color:var(--text) }

Security and nonces

When you add a REST endpoint to persist data, protect it with appropriate permission checks. For the sample provided we allowed only logged-in users and used the standard X-WP-Nonce header when calling the REST endpoint. If you expose any endpoints that change data, always validate input and capabilities.

Summary of key steps (quick checklist)

  1. Create CSS token variables and dark overrides using html[data-theme=dark].
  2. Add an accessible toggle button in your theme templates.
  3. Implement a small inline script in the head (via wp_head) that reads localStorage or user meta and sets data-theme before CSS paints.
  4. Enqueue a main JS file that attaches the toggle, updates aria attributes, writes to localStorage safely, listens for storage events, and optionally POSTs to a server endpoint for logged-in users.
  5. Optionally register a REST route to save preference in user_meta for logged-in users and use wp_create_nonce to allow the JS to write securely.
  6. Test: first visit, logged-in restore, cross-tab sync, system preference changes, private browsing.

Appendix: Troubleshooting tips

  • If you see a flash of the wrong theme on load, make sure the inline script runs before the styles — place it early in head and use priority 1 for the wp_head action.
  • If localStorage writes fail in some browsers, ensure wrappers use try/catch and degrade gracefully.
  • If server-saved preference doesnt appear on first load, ensure your server-side check and the early head script print a concrete dark or light string and not an unescaped value.
  • For caching/CDN: anonymous users cannot reliably have server-based theme switches unless you vary caches by cookie/edge logic — usually not worth it prefer client-side localStorage for speed.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *