-2

1. Original Question & First Bounty

Given a very basic color scheme.
One that allows the website user to set a desired theme from a predefined set of CSS root variable definitions.

Is there an easy way to let the browser remember the theme, it set by the user, so that the user's input is carried over to the next pages? Thus eliminating the need for setting the color scheme on every new page!

const setTheme = theme => document.documentElement.className = theme;
document.getElementById('scheme').addEventListener('click', ({target}) => {
  setTheme(target.getAttribute('id'));
});
html{margin: 10px}
#scheme p{ /* User Interface */
  display: inline-block;
  text-decoration: underline;
}#scheme p:hover{cursor: pointer}


:root{ /* Default Theme */
  --bgr: #eee;
  --txt: #000;
}
:root.light {
  --bgr: #ddc;
  --txt: #466;
}
:root.dark {
  --bgr: #222;
  --txt: #A75;
}
:root.blue{
  --bgr: #246;
  --txt: #eec;
}


body { /* Have something to test */
  background: var(--bgr);
  color: var(--txt);
}
<div id="scheme">
    <p id="light">Light</p>
    <p id="dark">Dark</p>
    <p id="blue">Blue</p>
    <p id="etc">Etc</p>
</div>

<h1>Click on a theme to change the color scheme!</h1>

2. Updated Precision & Second Bounty

Attention to the Original Question and Improvements Needed
The current two answers have some problems from the point of view of the original question: one answer has A)diverged away from the css root: {}/* Default Theme */ by introducing root.default{} and B) has implemented automatic theme selection with "light"/"dark" an and added "auto" theme, which though awesome for some, is the opposite of what is asked in the question: a simple manual user choice, overruling just one "unset" root: {} default theme.

The other answer thought nice and basic C) necessitates manually setting the optional CSS theme names in an JavaScript array, making it prone to future errors and would be nice not need setting because all manual theme options are consistently named like :root.themename{}. D) Also this solution causes a second or so delay when setting themes in mobile iOS devices!?


2nd Bounty goes to new answers that check the most of these points:

  • Stick to the original questions root:{} as the only default (unset theme). *1)
  • Nothing beyond the basics, no automatic theme selection please.
  • Do not necessitate css theme names in JavaScript code.
  • Allow SVG boxes as buttons for more design flexibility. *2)

*1) The reason why I want root: {} as default theme, is because I would like to set CSS Filters like saturation and grayscale on the themes that impact the entire page, images, logos, everything!

*2) Simpler cleaner html with svg buttons for setting the themes. In this future third and last bounty, plain SVG boxes (with one or more colours) serve as buttons for setting the themes! How awesome would that be?! Wordless, Timeless! See snippet below.

:root{ /* Default Theme, if no theme is manually selected by user */
  --bgr: #eee;
  --txt: #000;
  --flt: none;
  }
:root.blackwhite{
  --bgr: #fff;
  --txt: #000;
  --flt: contrast(100%) grayscale(100%);
  }
:root.sepia {
  --bgr: lightblue;
  --txt: red;
  --flt: sepia(75%);
  }
:root.holiday{
  --bgr: #fba;
  --txt: #269;
  --flt: blur(.25px) saturate(4);
  }
:root.moody{
  --bgr: green;
  --txt: yellow;
  --flt: drop-shadow(16px 16px 20px yellow) blur(1px);
  }


html { /* Have something to test */
  background: var(--bgr);
  color: var(--txt);
  filter: var(--flt); /* important filter that affects everything */
  }

h1{
  background: var(--bgr);
  color: var(--txt);
  }

theme{ /* html element div that contains only SVG graphics */
  display: flex;
  flex-direction: row;
  }
<!--I prefer a custom more logical `<theme>` over an equally
 meaningless <div id="theme"> as container, since both do not
 carry any semantic meaning. But, if you prefer a standard div,
then that's fine! Just explain why that's better.-->

<theme>
  <svg id="blackwhite"><rect width="100" height="50" /></svg>
  <svg id="sepia"     ><rect width="100" height="50" /></svg>
  <svg id="holiday"   ><rect width="100" height="50" /></svg>
  <svg id="moody"     ><rect width="100" height="50" /></svg>
</theme>

<h1>Click on a theme to change the color scheme!</h1>
<p>Some Paragraph texts.</p>
<img src="\clouds.jpg" alt="clouds"/>

3. Updated User Interface Feedback & Third Bounty

Show the user which theme he has manually clicked on.
Add a class .chosentheme to the svg element that is currently chosen or active, so that the user can see which one of the theme buttons is currently active/chosen/selected!

<theme id="scheme">
  <svg id="blackwhite"><rect/></svg>
  <svg id="midnight"><rect/></svg>
  <svg id="beach"><rect/></svg>
  <svg           ><rect/></svg><!-- currently works as a (dummy) button to activate the :root{} Default Theme -->
</theme>

If no svg is selected yet, or if the memory is empty, then the last svg could be automatically selected via CSS or via setting an exception for this one id in the code like "defaulttheme", <svg id="defaulttheme">, which already works as a (dummy) button to load the default theme :root{}.

(It's okay if by default, when the memory is empty, nothing is selected, even not the last default theme svg).

Then, if any of the svg's is clicked or if a theme is loaded from memory, then the .chosentheme styling should be applied via JavaScript dynamically, and added to that svg element's list of class names, letting the user know that he has manually clicked on that theme or that that theme is currently already loaded and showing.

theme svg.chosentheme { border: 1px solid black }
/* Sets a border around the currently activated theme */
/* Because someone clicked on it or because its stored in memory */

halfer
  • 19,824
  • 17
  • 99
  • 186
Sam
  • 15,254
  • 25
  • 90
  • 145
  • 5
    Put it into local storage, and retrieve it on pageload – CertainPerformance May 27 '22 at 23:34
  • 1
    Or maybe cookies. – Archit Gargi May 28 '22 at 00:38
  • 1
    While the effort in this question is impressive (34 edits!) it should have been three questions. What is left now is not salvageable by editing - it is several questions mixed together, with various conflicting answers below it, all serving different purposes. Although this undoubtedly solved the questions experienced by one engineer, I can't see how it is going to be useful to any future readers. I am not sure the original problem is clear either. – halfer Oct 03 '22 at 19:41
  • 2
    A good test of whether a question should be edited with an update, or a new question should be asked is this: if an answerer turns up in a year's time, can they hope to answer the whole thing in one answer? If the answer is no (say because the intent of the question has changed) then the new question ought to be asked separately. – halfer Oct 03 '22 at 19:47

3 Answers3

2

Try this simple resolve: to save, load and select a theme from local storage. Local Storage doesn't working in snippets or sandboxes.

The localStorage read-only property of the window interface allows you to access a Storage object for the Document's origin; the stored data is saved across browser sessions. MDN documentation

JS

// Select class name as in CSS file
const CLASS_NAME = 'chosentheme';

const scheme = document.getElementById('scheme');
// Creating an array of SVG elements
const svgElementsArray = [...scheme.querySelectorAll('svg')];
// Creating a color theme array using the SVG ID attribute
const themeNameArray = svgElementsArray.map(theme => theme.id);
// Get html node (html tag)
const htmlNode = document.documentElement;
// Get color (value) from local storage
const getLocalStorageTheme = localStorage.getItem('theme');

const setTheme = theme => {
  // Set class to html node
  htmlNode.className = theme;
  // Set theme color to local storage
  localStorage.setItem('theme', theme);

  svgElementsArray.forEach(svg => {
    // If we click on the svg and it has a class, do nothing
    if (svg.id === theme && svg.classList.contains(CLASS_NAME)) return;
    // Check, if svg has the same ID and if it doesn't have a class,
    // then we adding class and removing from another svg
    if (svg.id === theme && !svg.classList.contains(CLASS_NAME)) {
      svg.classList.add(CLASS_NAME);
    } else {
      svg.classList.remove(CLASS_NAME);
    }
  });
};

// Find current theme color (value) from array
const findThemeName = themeNameArray.find(theme => theme === getLocalStorageTheme);

// If local storage empty
if (getLocalStorageTheme) {
  // Set loaded theme
  setTheme(findThemeName);
} else {
  // Find last svg and set the class (focus)
  svgElementsArray.at(-1).classList.add(CLASS_NAME);
}

document.getElementById('scheme').addEventListener('click', ({ target }) => {
  // Getting ID from an attribute
  const id = target.getAttribute('id');
  // Find current theme color (value) from array
  const findThemeName = themeNameArray.find(theme => theme === id);
  setTheme(findThemeName);
});

We also need to prevent selection of child elements inside the button (SVG) and to select exactly the button with ID attribute.

CSS

theme svg > * {
  pointer-events: none;
}
theme svg {
  /* to prevent small shifts, 
  when adding the chosentheme class */
  border: 1px solid transparent;
}
theme svg.chosentheme {
  border-color: black;
}

To prevent the webpage from flickering (blinking) while is loading, place this snippent at the top of the head tag. (prevent dark themes from flickering on load)

HTML

<head>
  <script>
    function getUserPreference() {
      if(window.localStorage.getItem('theme')) {
        return window.localStorage.getItem('theme')
      }
    }
    document.documentElement.dataset.theme = getUserPreference();
  </script>
  ....
</head>

// Select class name as in CSS file
const CLASS_NAME = 'chosentheme';

const scheme = document.getElementById('scheme');
// Creating an array of SVG elements
const svgElementsArray = [...scheme.querySelectorAll('svg')];
// Creating a color theme array using the SVG ID attribute
const themeNameArray = svgElementsArray.map(theme => theme.id);
// Get html node (html tag)
const htmlNode = document.documentElement;
// Get color (value) from local storage
const getLocalStorageTheme = localStorage.getItem('theme');

const setTheme = theme => {
  // Set class to html node
  htmlNode.className = theme;
  // Set theme color to local storage
  localStorage.setItem('theme', theme);

  svgElementsArray.forEach(svg => {
    // If we click on the svg and it has a class, do nothing
    if (svg.id === theme && svg.classList.contains(CLASS_NAME)) return;
    // Check, if svg has the same ID and if it doesn't have a class,
    // then we adding class and removing from another svg
    if (svg.id === theme && !svg.classList.contains(CLASS_NAME)) {
      svg.classList.add(CLASS_NAME);
    } else {
      svg.classList.remove(CLASS_NAME);
    }
  });
};

// Find current theme color (value) from array
const findThemeName = themeNameArray.find(theme => theme === getLocalStorageTheme);

// If local storage empty
if (getLocalStorageTheme) {
  // Set loaded theme
  setTheme(findThemeName);
} else {
  // Find last svg and set the class (focus)
  svgElementsArray.at(-1).classList.add(CLASS_NAME);
}

document.getElementById('scheme').addEventListener('click', ({
  target
}) => {
  // Getting ID from an attribute
  const id = target.getAttribute('id');
  // Find current theme color (value) from array
  const findThemeName = themeNameArray.find(theme => theme === id);
  setTheme(findThemeName);
});
:root {
  /* Default Theme, if no theme is manually selected by user */
  --bgr: #eee;
  --txt: #000;
  --flt: none;
}

:root.blackwhite {
  --bgr: #fff;
  --txt: #000;
  --flt: contrast(100%) grayscale(100%);
}

:root.midnight {
  --bgr: lightblue;
  --txt: red;
  --flt: sepia(75%);
}

:root.beach {
  --bgr: #fba;
  --txt: #269;
  --flt: blur(0.25px) saturate(4);
}

/* :root.moody {
  --bgr: green;
  --txt: yellow;
  --flt: drop-shadow(16px 16px 20px yellow) blur(1px);
} */

html {
  /* Have something to test */
  background: var(--bgr);
  color: var(--txt);
  filter: var(--flt);
  /* important filter that affects everything */
}

h1 {
  background: var(--bgr);
  color: var(--txt);
}

theme {
  /* html element div that contains only SVG graphics */
  display: flex;
  flex-direction: row;
}

theme svg > * {
  /* prevent selection */
  pointer-events: none;
}

theme svg {
  /* to prevent small shifts, 
  when adding a focus class */
  border: 1px solid transparent;
}

theme svg.chosentheme {
  border-color: black;
}
<theme id="scheme">
  <svg id="blackwhite"><rect /></svg>
  <svg id="midnight"><rect /></svg>
  <svg id="beach"><rect /></svg>
  <svg id="defaulttheme"><rect /></svg>
</theme>

<h1>Click on a theme to change the color scheme!</h1>
<p>Some Paragraph texts.</p>
Anton
  • 8,058
  • 1
  • 9
  • 27
  • Astounding, marvellous and timeless, Very customisable, brilliant! One question about that little script at the top of the header: I see absolutely no difference with or without it!? Is it okay if I don't use that script? I mean is that script on top completely optional or is it necessary for the theme to work properly? Some `//comments` in that top script would be nice. – Sam Jun 14 '22 at 23:58
  • 1
    I added this script, because when you will have the dark theme by default or user will select dark theme you can get a flickering (blinking) effect, use the script to avoid this effect. If all themes are light or have light tints, then script is no needed. – Anton Jun 15 '22 at 06:32
  • Anton just wanted to let you know I've set and updated the 3rd bounty and I hope this improvement is small, smart, solveable and without a sweat! Thanks in advance! – Sam Jun 16 '22 at 13:56
  • @Sam. I updated the snippet, should work fine. – Anton Jun 16 '22 at 16:53
  • 1
    Wow Anton impressive work! It works fantastically! The award is yours. Thank you. I'm kind of sad that this project is finished now... But maybe there are other things on which we can continue to work together! :) – Sam Jun 17 '22 at 08:41
1

There are several ways to to persist data in the browser between page loads:

  • cookies
  • localStorage
  • sessionStorage
  • IndexedDB

I suggest using localStorage. Here is a very detailed comparison of the different methods.


update:

localStorage never expires. The user may manually delete/reset the localStorage via the browser settings/dev console.

You can control the expiration of values in localStorage by storing the expiration timestamp with the value and checking the timestamp each time the value is retrieved. (If you just want the values to expire after a page session, use sessionStorage.)

amplify.store is a wrapper around the browser storage API's. It lets you set an optional expiration time.

Leftium
  • 16,497
  • 6
  • 64
  • 99
  • Thanks for this answer about the different methods of data saving options in browsers. At which point (in time or after which activities) will the `localStorage` dissappear, be flushed or cleaned or deleted or just reset? Please add this information to your answer. – Sam Jun 09 '22 at 07:11
  • 1
    @Sam localStorage never expires. I added more info to my answer. – Leftium Jun 09 '22 at 11:44
  • Thanks for the added clarity about the un-expiring infinite lifespan of localStorage! Cookies shall be jealous. – Sam Jun 09 '22 at 23:59
1

Using matchMedia and prefers-color-scheme you can apply a default theme based on the user's system-wide preference. This will adjust automatically if the user has enabled auto-switching based on the time of day or through the light sensor on their device.

Then, if they choose to override this, save their selection in localStorage. This preference will remain until the user clears the storage for your origin.

<!DOCTYPE html>
<head>
  <title> Theme Selector Test </title>
  <style>
    :root.default { --bgr: #eee; --txt: #000; }
    :root.light { --bgr: #ddc; --txt: #446; }
    :root.dark { --bgr: #222; --txt: #a75; }
    :root.blue { --bgr: #246; --txt: #eec; }
    body { background: var(--bgr); color: var(--txt); margin: 1.5rem; }
  </style>
  <script>
    let prefersDark = matchMedia('(prefers-color-scheme: dark)');
    prefersDark.addEventListener('change', event => loadTheme());

    function setTheme(theme) {
      if (theme == 'auto') {
        localStorage.removeItem('theme');
        loadTheme(null);
      } else {
        localStorage.setItem('theme', theme);
        applyTheme(theme);
      }
    }

    function loadTheme(theme) {
      theme = localStorage.getItem('theme');
      theme ??= (prefersDark.matches) ? 'dark' : 'default';
      applyTheme(theme);
    }

    function applyTheme(theme) {
      document.documentElement.className = theme;
    }

    window.setTheme = setTheme;

    loadTheme();
  </script>
</head>
<body>
  <h1> Select a theme to change the color scheme! </h1>
  <select id="scheme">
    <option value="auto">Auto</option>
    <option value="default">Default</option>
    <option value="light">Light</option>
    <option value="dark">Dark</option>
    <option value="blue">Blue</option>
  </select>
  <script>
    let selector = document.getElementById('scheme');
    selector.value = localStorage.getItem('theme') || 'auto';
    selector.addEventListener('click', event => window.setTheme(selector.value));
  </script>
</body>

See this answer for instructions on how to simulate the system-wide preference for testing purposes.

As @Anton mentioned, localStorage doesn't work in snippets here on Stack Overflow due to sandboxing so instead I've written this as a full page example to demonstrate the best way to implement it in a real-world environment.

I have also published an ES Module version of this. Implementing the inline version as demonstrated in this post is better for performance but the module version is better if you want to avoid polluting the global scope.

I've used a <select> element in the example since this is probably how most user's who find this in the future will likely want to use it. To display the options like you have shown in your question you can implement as demonstrated below. Note that I've replaced the <p> tags with <button> for better accessibility. I've also added an extra check in the click handler to avoid setTheme from being called if the background area of the container <div> is clicked.

In your CSS :

#scheme button {
  border: 0;
  background: none;
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
 }

#scheme button * {
  pointer-events: none;
}

In your HTML <body> :

<div id="scheme">
  <button id="auto">Auto</button>
  <button id="default">Default</button>
  <button id="light">Light</button>
  <button id="dark">Dark</button>
  <button id="blue">Blue</button>
</div>
<h1>Click on a theme to change the color scheme!</h1>
<script>
  let selector = document.getElementById('scheme');
  selector.addEventListener('click', event => {
    if (event.target == selector) { return; }
    window.setTheme(event.target.id);
  });
</script>

If used inside a form, you'll need to add event.preventDefault(); to the click handler to avoid submitting when the buttons are clicked.

Besworks
  • 4,123
  • 1
  • 18
  • 34
  • Thanks Besworks! I'm awarding this answer because I think it serves the community best. Two final questions: Is it okay if I put the script in the body and not in the head? The memory does seem to work and I thought I read somewhere in google speed pages that "its best to put js scripts at the bottom of the page", https://jsfiddle.net/eu097xsd/ For my second final question, I've opened a new question which is about why SVG does not work as a direct replacement of TEXT in the correct button divs: https://stackoverflow.com/q/72590643/509670 – Sam Jun 12 '22 at 08:39
  • 1
    Only scripts that need to select elements from the page need to be at the bottom. That's why I've split it into to parts. The part in the head needs to be there so it can run before the content loads and allow the theme to be available as soon as possible, otherwise you will see unstyled content while the page is loading. The script that handles the selector goes at the end of the body so that it can access the elements which it attaches to. – Besworks Jun 12 '22 at 10:50