Artifacts Gallery Guilds Search Wiki Login Register

Welcome, Guest. Please login or register. - Thinking of joining?
a Summer day - @769.13
Activity rating: Four Stars Posts & Arts: 55/1k.beats Random | Recent Posts | Guild Recents
News: :skull: Websites are like whispers in the night  :skull: Guild Events: Summerween Watch-a-thon

+  MelonLand Forum
|-+  Materials & Info
| |-+  ♺ ∙ Web Crafting Materials
| | |-+  Tutorial: CSS-based theme switcher (with optional JS/localStorage memory)


« previous next »
Pages: [1] Print Embed
Author Topic: Tutorial: CSS-based theme switcher (with optional JS/localStorage memory)  (Read 78 times)
Dan Q
Hero Member ⚓︎
*****
View Profile WWWArt


I have no idea what I am doing
⛺︎ My Room
RSS: RSS

Guild Memberships:
Artifacts:
Dan Q Cruisin'I DIDN'T meet Dan Q on Melonland!Visited on Melon's 10th Anniversary!
« on: a Summer day » Embed

There are a lot of "theme switchers" out there that require server-side code (e.g. PHP) and/or JavaScript, but did you know that you can make a theme switcher than uses just HTML + CSS. (And then optionally enhance it with a little JavaScript to help it "remember" what theme was selected... but the switcher will still work fine even if JavaScript is disabled!)

I've made an Everyone Page to demonstrate. Here's how it works:

Step 1: add a theme switcher

A theme switcher can just be a series of radiobuttons: <input type="radio">. If radiobuttons share a name="...", then web browsers will automatically un-check all the others with the same name when the user checks any of them, so only one will be checked at once. You can choose which one the initial default is with checked.

Wrap that up with some <label>s to make them easy to click/for best accessibility, and you get something like this (I've got three themes: "basic", "dark", and "pink"):


Code
<div class="theme-switcher">
  <h2>
    Theme:
  </h2>

  <label for="theme-switcher-option-basic">
    Basic
    <input type="radio" name="theme-switcher-option" id="theme-switcher-option-basic" checked="">
  </label>

  <label for="theme-switcher-option-dark">
    Dark
    <input type="radio" name="theme-switcher-option" id="theme-switcher-option-dark">
  </label>

  <label for="theme-switcher-option-pink">
    Pink
    <input type="radio" name="theme-switcher-option" id="theme-switcher-option-pink">
  </label>
</div>

Note that I've wrapped them all in a <div class="theme-switcher"> so they're easy to find later (and so I can style my switcher widget), that my radiobuttons all share the same name="theme-switcher-option" so I can only select one at a time, and that they each have a different id="..." (which I'll use to work out which one is checked) which is referenced by the <label for="..."> (for accessibility... and for a stylistic thing I'll do later!).

Step 2: change the theme based on the switcher

The magic here is that we can use CSS's :has(...) and :checked selectors to work out which theme is selected, and CSS nesting to allow us to target elements cleanly within "a page that has a particular theme selected".

To begin with, we can write a basic theme: our default, if you like:


Code
body {
  background: #fff;
  color: #333;
  font-family: Seravek, 'Gill Sans Nova', Ubuntu, Calibri, 'DejaVu Sans', source-sans-pro, sans-serif;
}

Now we can use some magic CSS to say "do something different if the body has a particular theme radiobutton checked:

Code
/* Dark theme: */
body:has(#theme-switcher-option-dark:checked) {
  background: #333;
  color: #fff;
}

/* Pink theme: */
body:has(#theme-switcher-option-pink:checked) {
  background: pink;
  color: #000;
  font-family: 'Segoe Print', 'Bradley Hand', Chilanka, TSCu_Comic, casual, cursive; 
}

You're not limited to just styling the body, though. Thanks to CSS nesting, we can (easily) target other elements too. Let's make the <h1>...</h1> inside the dark theme align to the center and gain a moon and star emoji on each side of it:

Code
/* Dark theme: */
body:has(#theme-switcher-option-dark:checked) {
  background: #333;
  color: #fff;

  /* We can also target specific page elements: let's make the <h1> change too: */
  h1 {
    text-align: center;

    &::before {
      content: '🌝 ';
    }

    &::after {
      content: ' ⭐️';
    }
  }
}

You can even make whole parts of the page disappear and re-appear. For example, if you wanted to make a particular part of the page invisible, except in the "dark" and "pink" themes, you could do something like this:

Code
<p class="theme-specific theme-specific-dark theme-specific-pink">
  This paragraph only appears when you're in the 'dark' or 'pink' themes!
</p>

<style>
  /* Anything with class="theme-specific" gets HIDDEN by default - themes can re-show them! */
  .theme-specific {
    display: none;
  }

  /* In the dark theme, dark-specific things get un-hidden (revert to their default state): */
  body:has(#theme-switcher-option-dark:checked) {
    .theme-specific-dark {
      display: unset;
    }
  }

  /* In the pink theme, pink-specific things get un-hidden (revert to their default state): */
  body:has(#theme-switcher-option-pink:checked) {
    .theme-specific-pink {
      display: unset;
    }
  }
</style>

Step 3: (optionally) add some JavaScript to "save" the state

I put the following code at the end of my <body>, right before the </body>. It's critically important that it appears after the theme switcher, or it won't be able to find it!

You can just use it as-is, or keep reading to understand how and why it works:


Code
<script>
  (function(){
    /* Set this to anything that won't clash with any other localStorage on your site! */
    const THEME_STORAGE_KEY = 'current-theme-id';

    /* When the theme changes, attempt to save it to localStorage. */
    const themeSwitcher = document.querySelector('.theme-switcher');
    if(!themeSwitcher) return; // No theme switcher on this page? Abort!

    /* Listen for any 'change' within the .theme-switcher: */
    themeSwitcher.addEventListener('change',()=>{
      // Get the ID of the currently-checked radiobutton:
      const selectedId = themeSwitcher.querySelector(':checked').id;
      // Save it to localStorage
      localStorage.setItem(THEME_STORAGE_KEY, selectedId);
    }, { capture: true, passive: true }); // capture: true steals the events from the <inputs>; passive: true promises the browser we'll never cancel events (helps performance)

    /* When the page loads, see if a 'theme' is set in localStorage: */
    const initialTheme = localStorage.getItem(THEME_STORAGE_KEY);
    if(initialTheme) {
      // If so, try to find the right radiobutton to hit:
      const themeButton = themeSwitcher.querySelector(`#${initialTheme}`);
      if(!themeButton) return; // Don't have the requested theme? Abort!
      // Click the right button immediately!
      themeButton.click();
    }
  })();
</script>

Here's what that code does:

- It's wrapped in an IIFE - (function(){ ... })(); - a function with no name, that gets run immediately. This helps isolate the code from any other JavaScript code on the page and ensures that any variables defined in one don't pollute the other: it's just tidy!
- First, it tries to find the thing with class="theme-switcher". If it can't, it uses return; to stop right now (this is another benefit of an IIFE: you can use return; to bomb out of them early if the conditions aren't right for them to run! This helps ensure that the code doesn't throw errors if you include the JavaScript on every page (e.g. via a separate file: this is a good idea, by the way!) but you fail to include the theme switcher widget!
- It uses addEventListener to listen for 'change' events within the theme switcher; these will be triggered any time the checked-state of one of the radiobuttons within changes. We don't much care which one was clicked: we do the same thing every time anyway:
- When one is clicked, we find the first one that's :checked, get its id, and write that to localStorage. localStorage is a convenient feature of most browsers that works a bit like cookies, but for JavaScript only. You can see (and modify!) what's in your localStorage using your browser debug tools. It's scoped per-site, so a different site's JavaScript can't see your site's localStorage.
- When the page initially loads, we try to get a value from the same part of localStorage. If we find it, and if we can find something with that id within the theme selector... then we click it rightaway! In this way, once the user's selected a theme, it automatically gets clicked for them on every subsequent page load.

Give it a go on the Everyone site! Change the theme on that page and see the effects, and then close the tab and then go it again to see that it remembered your choice. And consider using your browser debug tools (look under Storage) to see if you can find where your choice of theme got saved. Can you manually change it and refresh to see the effect, without clicking a button at all?

Logged

https://danq.me/_q26t/badges/dan-q-88x31-lighter.gif https://danq.me/_q26t/badges/dan-q-88x31-peekaboo-scroller.gif https://beige-buttons.danq.dev/beige-buttons-88x31.gif https://embed-html.danq.dev/embed-html-88x31.gif

Artifact Swap: PolyamorousI met Dan Q on Melonland!Doctor RedactedJoined 2025!
Limette
Jr. Member ⚓︎
**
View Profile WWWArt


⛺︎ My Room

Guild Memberships:
Artifacts:
Visited on Melon's 10th Anniversary!I got robbed by Dan Q on Melonland!Joined 2025!
« Reply #1 on: a Summer day » Embed

OH, neat! Could this system also be used for adding multiple languages to your website, or would it be better practice to make separate pages per language (like /en/blog and /de/blog for blogs in English and German, respectively) since that way you can specify the lang attribute in the html tag?

Sorry, my first thought seeing this was just that it seemed like it could be used for far more than just a theme switcher, especially since you demonstrated hiding/displaying elements based on which theme is active.

It's always fun reading your tutorials, they tackle very useful topics and are incredibly well-explained, and make me want to go try them out on my own site, and think about ways the code could be repurposed for other things.

Logged

https://limette.neocities.org/assets/limette.gifhttps://file.garden/aJDdYDydqnG0q1NR/blinkies/exinc.gif
Dan Q
Hero Member ⚓︎
*****
View Profile WWWArt


I have no idea what I am doing
⛺︎ My Room
RSS: RSS

Guild Memberships:
Artifacts:
Dan Q Cruisin'I DIDN'T meet Dan Q on Melonland!Visited on Melon's 10th Anniversary!
« Reply #2 on: a Summer day » Embed

It could be used like that. It's probably not the best approach from an SEO perspective, but if that's not your priority, go for it! It gives a fringe benefit that where CSS isn't supported (e.g. in a text mode browser) all the content will be displayed, which - tagged with the language - is probably the highest-usability way to make a page work!

I'd use the (standardised) lang attribute for this. Maybe something like:


Code
<style>
  /* Hide ALL language-specific content within the <body> to begin with: */
  body [lang] {
    display: none;
  }

  /* Show the right language-specific content, based on the language chosen in the language-switcher: */
  body:has(#language-switcher-option-en:checked) [lang="en"],
  body:has(#language-switcher-option-de:checked) [lang="de"] {
    display: unset;
  }
</style>

<div class="language-switcher">
  <h2>
    <span lang="en">
      Language:
    </span>
    <span lang="de">
      Sprache:
    </span>
  </h2>
 
  <label for="language-switcher-option-en">
    English
    <input type="radio" name="language-switcher-option" id="language-switcher-option-en" checked>
  </label>
 
  <label for="language-switcher-option-de">
    Deutsch
    <input type="radio" name="language-switcher-option" id="language-switcher-option-de">
  </label>
</div>

<div lang="en">
  <p>
    This is my English content.
  </p>
</div>

<div lang="de">
  <p>
    Das ist mein deutscher Inhalt.
  </p>
</div>

Logged

https://danq.me/_q26t/badges/dan-q-88x31-lighter.gif https://danq.me/_q26t/badges/dan-q-88x31-peekaboo-scroller.gif https://beige-buttons.danq.dev/beige-buttons-88x31.gif https://embed-html.danq.dev/embed-html-88x31.gif

Artifact Swap: PolyamorousI met Dan Q on Melonland!Doctor RedactedJoined 2025!
Dan Q
Hero Member ⚓︎
*****
View Profile WWWArt


I have no idea what I am doing
⛺︎ My Room
RSS: RSS

Guild Memberships:
Artifacts:
Dan Q Cruisin'I DIDN'T meet Dan Q on Melonland!Visited on Melon's 10th Anniversary!
« Reply #3 on: a Summer day » Embed

If this tickled your interests, BTW, I wrote a blog post last year about how you can implement a series of checkboxes plus OR and AND filters, entirely in CSS, to filter a list of animals by their characteristics (e.g. aquatic, carnivore, invertebrate): https://danq.me/2025/05/07/dynamic-filters-in-pure-css/

Logged

https://danq.me/_q26t/badges/dan-q-88x31-lighter.gif https://danq.me/_q26t/badges/dan-q-88x31-peekaboo-scroller.gif https://beige-buttons.danq.dev/beige-buttons-88x31.gif https://embed-html.danq.dev/embed-html-88x31.gif

Artifact Swap: PolyamorousI met Dan Q on Melonland!Doctor RedactedJoined 2025!
Pages: [1] Print Embed 
« previous next »
 

Melonking.Net © Always and ever was! SMF 2.0.19 | SMF © 2021 | Privacy Notice | Send Feedback | Supporters ♥ Forum Guide | Rules | RSS | WAP | Mobile


MelonLand Badges and Other Melon Sites!

MelonLand Project! Visit the MelonLand Forum! Support the Forum
Visit Melonking.Net! Visit the Gif Gallery! Pixel Sea TamaNOTchi
@000 MelonLand Zap! Want to Login or Join ? Forum Art Hub Chat Webring Editor Recent Edits Tenement Arcade Web Guides Graphics Catalogue Wiki Newsletters Image Stream
Melon's Sites TamaNotchi Textures PixelSea GifyPet MoMG Ozwomp Online Loom Videos Leaky Webring Melonking
Tools Melon Software ArtHub Embed Maker ML Passports
Outlinks Webrings Internet Phone Book HTML Energy Declarations Hackers & Designers Frutiger Aero Forum m15o's Web Services 32Bit Cafe iMood
Minecraft: Online
Join: craft.melonking.net