|
<body> |
|
|
|
<h1>Light & dark mode, with user-switch button</h1> |
|
|
|
<p>Inherits dark mode from the OS when enabled, and allows switching in-browser.</p> |
|
|
|
<p>Reads the current OS preference and adds an attribute <code>data-lightMode</code> to the <code><body></code> tag reflecting its dark or light value. If unsupported in the OS the default is set to light, and switching is still made available. Uses local storage to persist user selection across pages and visits.</p> |
|
|
|
|
|
<figure> |
|
<figcaption>JavaScript added data attribute</figcaption> |
|
<pre><code class=language-markup spellcheck=false contenteditable><body data-lightMode="[light | dark]"></code></pre> |
|
</figure> |
|
|
|
|
|
<figure> |
|
<figcaption>Set CSS variables for OS light, or no-preference, mode</figcaption> |
|
<pre><code class=language-css spellcheck=false contenteditable>body { |
|
--color: hsl(269,19%,30%); |
|
--bgColor: hsla(32,100%,85%,.35); |
|
--imgFilter: none; |
|
--linkColor: hsl(214, 50%, 50%); |
|
--bgPage: url("data:image/svg+xml;charset=utf8,%3Csvg width='100%25' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.4'/%3E%3C/filter%3E%3C/defs%3E%3Crect filter='url(%23a)' opacity='.3' width='100%25' height='100%25'/%3E%3C/svg%3E"); |
|
}</code></pre> |
|
</figure> |
|
|
|
|
|
<figure> |
|
<figcaption>Media query and variables for OS dark mode</figcaption> |
|
<pre><code class=language-css spellcheck=false contenteditable>@media (prefers-color-scheme: dark) { |
|
body:not([data-lightMode="light"]) { |
|
--color: #fafafa; /* Not quite white */ |
|
--bgColor: hsl(269, 19%, 5%); |
|
--imgFilter: grayscale(10%) brightness(90%); |
|
--linkColor: hsl(214, 100%, 85%); |
|
--bgPage: url("data:image/svg+xml;charset=utf8,%3Csvg width='100%25' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.4'/%3E%3C/filter%3E%3C/defs%3E%3Crect filter='url(%23a)' opacity='.3' width='100%25' height='100%25'/%3E%3C/svg%3E"); |
|
} |
|
}</code></pre> |
|
</figure> |
|
|
|
<p><strong>Note:</strong> <code>body:not([data-lightMode="light"])</code> prevents the media query CSS activating while the mode-button is set to light.</p> |
|
|
|
<p>We need a second copy of the variables, so the mode-button can also offer dark mode.</p> |
|
|
|
<figure> |
|
<figcaption>Overwrite OS light mode with dark</figcaption> |
|
<pre><code class=language-css spellcheck=false contenteditable>body[data-lightMode="dark"] { |
|
--color: #fafafa; /* Not quite white */ |
|
--bgColor: hsl(269, 19%, 5%); |
|
--imgFilter: grayscale(10%) brightness(90%); |
|
--linkColor: hsl(214, 100%, 85%); |
|
--bgPage: url("data:image/svg+xml;charset=utf8,%3Csvg width='100%25' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.4'/%3E%3C/filter%3E%3C/defs%3E%3Crect filter='url(%23a)' opacity='.3' width='100%25' height='100%25'/%3E%3C/svg%3E"); |
|
}</code></pre> |
|
</figure> |
|
|
|
<figure> |
|
<figcaption>Use the CSS variables</figcaption> |
|
<pre><code class=language-css spellcheck=false contenteditable>body { |
|
color: var(--color); |
|
background-color: var(--bgColor); |
|
background-image: var(--bgPage); |
|
transition: |
|
background-image .3s ease-out, |
|
background-color .3s ease-out, |
|
color .3s ease-out; |
|
} |
|
img:not([src*=".svg"]) { |
|
filter: var(--imgFilter); |
|
} |
|
a:link, |
|
a:visited { |
|
color: var(--linkColor); |
|
}</code></pre> |
|
</figure> |
|
|
|
|
|
|
|
<h2>Javascript anyone?</h2> |
|
|
|
<p>Local Storage is used to persist the mode selected, light or dark, across pages and visits.</p> |
|
|
|
<figure> |
|
<figcaption>Supports ES6 & local storage?</figcaption> |
|
<pre><code class=language-javascript spellcheck=false contenteditable>var supportsES6=(function(){try{new Function('(a=0)=>a');return true}catch(err){console.log('No ES6');return false}}()); |
|
var supportsLocalStorage=(function(){try{var m=new Date().valueOf()+"";localStorage.setItem(m,m);localStorage.removeItem(m);return true}catch(e){console.log("localStorage unavailable");return false}}());</code></pre> |
|
</figure> |
|
|
|
|
|
<p>JavaScript adds a mode-toggle button, containing an SVG, to the HTML, which allows the switching of light & dark modes.</p> |
|
|
|
|
|
<figure> |
|
<figcaption>Dark & Light mode toggle button</figcaption> |
|
<pre><code class=language-javascript spellcheck=false contenteditable>var Dark_Light_Mode_Toggle_Button = (function (window, document, supportsES6, supportsLocalStorage) { |
|
|
|
if (!supportsES6) return; |
|
|
|
const name = 'mode'; |
|
const btnClass = 'actions_btn-' + name; |
|
const svgClass = 'actions_svg-' + name; |
|
const clickedClass = '-js-clicked'; |
|
const [light, dark] = ['light', 'dark']; |
|
const body = document.body; |
|
const btn = document.createElement('button'); |
|
|
|
let mode; |
|
|
|
const _setAttr = (obj, attr, value) => obj.setAttribute(attr, value); |
|
const _modeText = bool => bool ? light : dark; |
|
const _animEnd = e => btn.classList.remove(clickedClass); |
|
|
|
const _clicked = _ => { |
|
|
|
// Note: aria-clicked state is purposefully not linked to the mode setting. |
|
// Initially: The mode may be light or dark, but aria-clicked state is always false. |
|
_setAttr(btn, 'aria-clicked', btn.getAttribute('aria-clicked') === 'false'); |
|
|
|
mode = mode === false; |
|
|
|
// Note: color-scheme cannot be set with CSS variables |
|
// This setting is ignored where unsupported |
|
body.style.colorScheme = _modeText(mode); |
|
|
|
_setAttr(body, 'data-lightMode', _modeText(mode)); |
|
_setAttr(btn, 'aria-label', `Change to ${_modeText(!mode)} mode`); |
|
supportsLocalStorage && localStorage.setItem('mode', _modeText(mode)); |
|
btn.classList.add(clickedClass); |
|
btn.addEventListener('animationend', _animEnd, {once: true}); |
|
}; |
|
|
|
// Utilising symbol defs in the HTML |
|
// [Optionally embed the full SVG here] |
|
const _getSvg = _ => `<svg class="${svgClass}" aria-hidden=true focusable=false> |
|
<use class="${name}-${dark}" xlink:href="#icon-${name}-${dark}"></use> |
|
<use class="${name}-${light}" xlink:href="#icon-${name}-${light}"></use> |
|
</svg>`; |
|
|
|
const _getMode = _ => { |
|
|
|
// Get the OS mode setting |
|
mode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? false : true; |
|
|
|
// Override OS mode with the locally stored value. |
|
// Caters to state persistence across pages and visits. |
|
if (supportsLocalStorage && 'mode' in localStorage) { |
|
mode = localStorage.getItem('mode') === 'light'; |
|
} |
|
}; |
|
|
|
const _init = _ => { |
|
_getMode(); |
|
|
|
// Note: color-scheme cannot be set with CSS variables |
|
// This setting is ignored where unsupported |
|
body.style.colorScheme = _modeText(mode); |
|
|
|
_setAttr(body, 'data-lightMode', _modeText(mode)); |
|
_setAttr(btn, 'class', btnClass); |
|
_setAttr(btn, 'aria-clicked', false); |
|
_setAttr(btn, 'aria-label', `Change to ${_modeText(!mode)} mode`); |
|
|
|
btn.innerHTML = _getSvg(); |
|
btn.addEventListener('click', _clicked); |
|
|
|
// Note: Button is added at the end of the HTML to avoid preceding an accessibility skip-to-content link. |
|
// Skip-to-content links should always be the first actionable asset on a web page. |
|
body.appendChild(btn); |
|
}; |
|
|
|
_init(); |
|
|
|
}(window, document, supportsES6, supportsLocalStorage));</code></pre> |
|
</figure> |
|
|
|
|
|
<p>The property <code class=language-css>color-scheme: [light | dark];</code> informs the browser what modes the page supports, and <strong>may</strong>, where supported (Safari), adjust form controls and scrollbars to suit. Unfortunately, it cannot be set with CSS variables so JavaScript is used.</p> |
|
|
|
<p> |
|
<label>Select visual for testing: |
|
<select aria-label="Visual demonstration" role=presentation> |
|
<option>Unopinionated example</option> |
|
<option>Unopinionated example</option> |
|
<option>Unopinionated example</option> |
|
<option>Unopinionated example</option> |
|
</select> |
|
</label> |
|
<label>Text input visual for testing: |
|
<input type=text placeholder="Unopinionated example" role=presentation> |
|
</label> |
|
</p> |
|
|
|
<h2>Button styling</h2> |
|
|
|
|
|
<p>The button styling presented here is not exactly the button used, it omits <a target=_blank title="[new window]" href="https://codepen.io/2kool2/pen/aboydMX">button click animation</a>, but a fully working version none-the-less.</p> |
|
|
|
|
|
<figure> |
|
<figcaption>Basic button styling</figcaption> |
|
<pre><code class=language-css spellcheck=false contenteditable>button::-moz-focus-inner { |
|
border: 0; |
|
} |
|
.actions_btn-mode { |
|
position: fixed; |
|
z-index: 5; |
|
top: .5rem; |
|
right: .5rem; |
|
width: 3rem; |
|
height: 3rem; |
|
border: 0; |
|
background-color: transparent; |
|
color: inherit; |
|
} |
|
|
|
/* Switch icon between dark & light */ |
|
.actions_svg-mode > * { |
|
transition: opacity .3s ease-out; |
|
} |
|
[data-lightMode="light"] .mode-dark, |
|
[data-lightMode="dark"] .mode-light { |
|
opacity: 1; |
|
} |
|
[data-lightMode="light"] .mode-light, |
|
[data-lightMode="dark"] .mode-dark { |
|
opacity: 0; |
|
}</code></pre> |
|
</figure> |
|
|
|
|
|
<figure> |
|
<figcaption>Light & Dark icons as SVG symbol definitions</figcaption> |
|
<pre><code class=language-markup spellcheck=false contenteditable><svg style=display:none id=svg_definitions> |
|
<defs> |
|
<symbol viewBox="0 0 960 960" id="icon-mode-dark"> |
|
<circle cx="476" cy="480" r="458" fill-opacity=".25"/> |
|
<path d="M382 33C82 91-118 488 115 767c186 223 492 255 716 9a515 515 0 01-421-243c-94-157-56-368-28-500z"/> |
|
</symbol> |
|
<symbol viewBox="0 0 960 960" id="icon-mode-light"> |
|
<circle cx="479.5" cy="480.5" r="242"/> |
|
<path d="M480 800c22 0 40 18 40 40v80a40 40 0 01-80 0v-80c0-22 18-40 40-40zm480-320c0 22-18 40-40 40h-80a40 40 0 010-80h80c22 0 40 18 40 40zM706 763l57 56a40 40 0 1056-56l-56-57a40 40 0 10-57 57zm-509 56l57-56a40 40 0 10-57-57l-56 57a40 40 0 1056 56zm-77-379a40 40 0 010 80H40a40 40 0 010-80h80zm21-243l56 57a40 40 0 1057-57l-57-56a40 40 0 10-56 56zm622 57l56-57a40 40 0 10-56-56l-57 56a40 40 0 1057 57zM440 40v80a40 40 0 0080 0V40a40 40 0 00-80 0z"/> |
|
</symbol> |
|
</defs> |
|
</svg></code></pre> |
|
</figure> |
|
|
|
<section> |
|
<h2>Image example</h2> |
|
<p>A CSS filter is used to desaturate colour (-15%) and reduce image brightness (-15%) when in dark-mode.</p> |
|
<img src="https://websemantics.uk/unsplash/red-door.jpg" style="display:block;width:100%;max-width:40rem;margin:2rem auto" alt="Worn and flaky, red painted door - image example for the applied filters" |
|
</section> |
|
|
|
|
|
<h2>References</h2> |
|
|
|
<ul> |
|
<li><a target=_blank title="[new window]" href="https://hankchizljaw.com/wrote/create-a-user-controlled-dark-or-light-mode/">Create a user controlled dark or light mode</a></li> |
|
<li><a target=_blank title="[new window]" href="https://web.dev/prefers-color-scheme/">prefers-color-scheme</a></li> |
|
<li><a target=_blank title="[new window]" href="https://codepen.io/2kool2/pen/MEbeEg">Prism code highlighting (light & dark mode)</a></li> |
|
<li><a target=_blank title="[new window]" href="https://codepen.io/2kool2/pen/aboydMX">Fixed position animated SVG buttons</a></li> |
|
<li><a target=_blank title="[new window]" href="https://websemantics.uk/tools/svg-to-background-image-conversion/">SVG to CSS background-image converter</a></li> |
|
<!-- |
|
<li><a target=_blank title="[new window]" href=""></a></li> |
|
--> |
|
</ul> |
|
|
|
|
|
<!-- Icon Definitions --> |
|
|
|
<svg style=display:none id=svg_definitions> |
|
<defs> |
|
|
|
<symbol viewBox="0 0 960 960" id="icon-mode-dark"> |
|
<circle cx="476" cy="480" r="458" fill-opacity=".5"/> |
|
<path d="M382 33C82 91-118 488 115 767c186 223 492 255 716 9a515 515 0 01-421-243c-94-157-56-368-28-500z"/> |
|
</symbol> |
|
<symbol viewBox="0 0 960 960" id="icon-mode-light"> |
|
<circle cx="479.5" cy="480.5" r="242"/> |
|
<path d="M480 800c22 0 40 18 40 40v80a40 40 0 01-80 0v-80c0-22 18-40 40-40zm480-320c0 22-18 40-40 40h-80a40 40 0 010-80h80c22 0 40 18 40 40zM706 763l57 56a40 40 0 1056-56l-56-57a40 40 0 10-57 57zm-509 56l57-56a40 40 0 10-57-57l-56 57a40 40 0 1056 56zm-77-379a40 40 0 010 80H40a40 40 0 010-80h80zm21-243l56 57a40 40 0 1057-57l-57-56a40 40 0 10-56 56zm622 57l56-57a40 40 0 10-56-56l-57 56a40 40 0 1057 57zM440 40v80a40 40 0 0080 0V40a40 40 0 00-80 0z"/> |
|
</symbol> |
|
</defs> |
|
</svg> |
|
|
|
</body> |
|
|
|
|
|
<style id=basic_page_styles> |
|
*, *::after, *::before { |
|
box-sizing: inherit; |
|
} |
|
body { |
|
font-family: sans-serif; |
|
text-rendering: optimizeLegibility; |
|
line-height: 1.5; |
|
margin: 1rem; |
|
padding-bottom: 2rem; |
|
background-attachment: fixed; |
|
} |
|
h1,h2 { |
|
font-weight: 100; |
|
margin-top: 2rem; |
|
margin-bottom: 1rem; |
|
text-align: center; |
|
} |
|
h1 + p { |
|
font-size: 1.4rem; |
|
text-align: center; |
|
} |
|
p { |
|
font-size: 1.125rem; |
|
} |
|
ul, p { |
|
max-width: 40rem; |
|
margin: 1rem auto; |
|
} |
|
li { |
|
margin:.25rem 0; |
|
} |
|
figure { |
|
max-width: 40rem; |
|
margin: 3rem auto; |
|
} |
|
figcaption { |
|
font-size: 1.25rem; |
|
} |
|
pre { |
|
overflow: scroll; |
|
background-color: hsla(0,0%,100%, 0.3); |
|
} |
|
code { |
|
font-family: monospace, monospace; |
|
font-size: inherit; |
|
} |
|
label { |
|
display: block; |
|
margin: 1rem auto; |
|
max-width: 24rem; |
|
} |
|
</style> |
|
|
|
|
|
<style id=action_button_animations> |
|
|
|
/* Buttons (or links) */ |
|
|
|
[class^="actions_btn"], |
|
[class^="actions_btn"]:visited { |
|
|
|
/* button colors light */ |
|
--btn-fg: #503660; |
|
--btn-bg: #fafafd; |
|
--btn-fg-hover: #418cec; |
|
--btn-bg-hover: #fff; |
|
|
|
box-sizing: content-box; |
|
display: block; |
|
width: 2.25rem; |
|
height: 2.25rem; |
|
padding: 0.25rem; |
|
color: var(--btn-fg); |
|
background-color: var(--btn-bg); |
|
border: 0.125rem solid transparent; |
|
border-radius: 50%; |
|
box-shadow: 0 0.25em 0.25em rgba(0, 0, 0, 0.3); |
|
text-decoration: none; |
|
outline: 0 solid; |
|
transition: all 0.3s ease-out; |
|
} |
|
[class^="actions_btn"]:hover, |
|
[class^="actions_btn"]:focus { |
|
color: var(--btn-fg-hover); |
|
background-color: var(--btn-bg-hover); |
|
border-color: var(--btn-fg-hover); |
|
transform: scale(1.2); |
|
box-shadow: 0 0.5em 0.5em rgba(0, 0, 0, 0.4); |
|
} |
|
[class^="actions_svg"] { |
|
width: 2.25rem; |
|
height: 2.25rem; |
|
fill: currentColor; |
|
stroke: currentColor; |
|
border-radius: 100%; |
|
overflow: hidden; |
|
pointer-events: none; |
|
transition: transform 0.5s ease-out; |
|
} |
|
|
|
/* Button click animation */ |
|
|
|
[class^="actions_btn"].-js-clicked { |
|
animation: actions_btn-clicked 0.3s ease-out forwards; |
|
} |
|
@keyframes actions_btn-clicked { |
|
0% {transform: scale(1.2);} |
|
50% {transform: scale(0.8);} |
|
100% {transform: scale(1.2);} |
|
} |
|
|
|
/* Remove all buttons from printouts */ |
|
|
|
@media print { |
|
[class^="actions_container"] { |
|
display: none !important; |
|
} |
|
} |
|
</style> |
|
|
|
|
|
|
|
|
|
<!-- Codepen footer include --> |
|
[[[https://codepen.io/2kool2/pen/mKeeGM]]] |