Responsive light and dark modes with Hugo, SCSS, and Javascript
February 29, 2024 |
To start, external links work differently than internal ones on peterarsenault.industries.
If it’s an internal link, it’s a pretty standard <a href>
tag (hover this example). But when I link to another site, I want the user to understand they’re leaving this site, and it looks like this:
hover this example.
The external link has a different highlight style, opens in a new tab, and a New Window icon appears next to it. This is due to a custom piece of code in Hugo called a Shortcode.
Since this website has both a dark and a light theme, two sets of colors are defined. For example, in light mode the text color is black, while in dark mode it is near-white.
Using Hugo Themes, these configurations are usually written in themes/<name>/assets/_main.scss
as CSS or SCSS variables:
body {
min-width: $body-min-width;
color: var(--body-font-color);
background: var(--body-background);
}
These variables are defined in themes/<name>/assets/_defaults.scss
.
Within _defaults.scss
, two @mixin
classes are defined:
$body-min-width: 20rem !default;
// Themes
@mixin theme-light {
--color-link: #1574cf;
--body-background: white;
--body-font-color: black;
}
@mixin theme-dark {
--color-link: #84b2ff;
--body-background: #343a40;
--body-font-color: #e9ecef;
}
Note: I also included another SCSS variable (
$body-min-width
) that uses the$
syntax. This is SCSS/SASS specific, as aremixin
classes. However,var()
functions are supported natively in CSS.
With these simple class definitions, the style of the page changes depending on whether “theme-light” or “theme-dark” is called.
User preferences and @media queries
With only standard CSS, we have access to the logic that states:
“if OS_theme == dark, show
theme-dark
; else, showtheme-light
.”
But how does the website know what the OS theme is?
Sites that support light/dark modes make a @media query when they start rendering to ask the browser if the OS uses (or ‘prefers’) a dark or light theme.
In Hugo, the @media query is located in themes/<name>/assets/themes/_auto.scss
:
@media (prefers-color-scheme: dark) {
:root {
@include theme-dark;
}
}
@media queries
are an expansive topic, beyond this scope of this article. They can detect many things about a user through their preferences, and possibly fingerprint a user just from these preferences.
User-Agent Client Hints
Regarding the prefers-color-scheme
media query, I was interested in how exactly the browser communicates with the OS to obtain this value (which can be either dark
or light
). But this is not explained in the Media Queries Level 5 spec. For that, I had to dig into user agents and
HTTP Header Client Hints.
There is an analogous value to prefers-color-scheme
in the user agent client hint header Sec-CH-Prefers-Color-Scheme
:
GET / HTTP/1.1
Host: example.com
Sec-CH-Prefers-Color-Scheme: "dark"
Here’s a basic process for how client hints work at the user-agent level:
- The client makes a web request.
- The server responds, and optionally tells the client it supports the client hint via
Accept-CH: Sec-CH-Prefers-Color-Scheme
. This hint is usually expressed as a critical hint,Critical-CH: Sec-CH-Prefers-Color-Scheme
, which forces the request to retry while providing more information. - The client retries the web request, providing the following header:
Sec-CH-Prefers-Color-Scheme: "dark"
orSec-CH-Prefers-Color-Scheme: "light"
. - The server receives this preference, which a
@media
query can validate and determine which CSS values to serve via@media (prefers-color-scheme: dark)
.
The
User-Agent Client Hints API
points out some more data that can be obtained by the browser. Here are some example requests and responses:
// Low entropy hints don't give away much information
// that might be used to create a fingerprinting for a user.
> navigator.userAgentData.toJSON();
// response
{
"brands": [
{
"brand": "Chromium",
"version": "122"
},
{
"brand": "Not(A:Brand",
"version": "24"
},
{
"brand": "Google Chrome",
"version": "122"
}
],
"mobile": false,
"platform": "macOS"
}
// High entropy hints that could fingerprint a user
> navigator.userAgentData
.getHighEntropyValues([
"architecture",
"model",
"platform",
"platformVersion",
"fullVersionList",
])
.then((ua) => {
console.log(ua);
});
// response
{
"architecture": "arm",
"mobile": false,
"model": "",
"platform": "macOS",
"platformVersion": "13.6.4"
}
This is a much more effective way to get user information than the older navigator.userAgent
API.
For more information on client hints, this article is a great resource:
User preference media features client hints headers.
๐กโ I have a question out to the UA-CH team about how to disable high entropy client hints.
Serving different HTML content based on OS Theme using JavaScript
The information above describes how that CSS properties can be set based on preferences obtained from the user-agent (which communicates with the OS to obtain Sec-CH-Prefers-Color-Scheme
).
But what if we want to serve different HTML elements based on this same information? For example, in the case of the custom links described in the first paragraph. Let’s say I want to show a white New Window icon on dark mode, but a black icon on light mode.
To do this, you can interact with the CSS @media query in JavaScript using the matchMedia()
method:
window.matchMedia("(prefers-color-scheme:dark)");
You can also watch for changes to this value (for example, if light/dark OS theme is triggered by Sunset) using the .change
event listener:
window.matchMedia("(prefers-color-scheme:dark)")
.addEventListener('change',
function(e) {
console.log("Changed!");
})
In practice, you could have a function that checks the system color theme at load time and also set a watcher for changes. This way, whenever the system color theme changes, the icon is changed by an innerHTML
call.
let iconUrl = "/img/Icon-External-link.png"
let defaultSystemMode = window.matchMedia("(prefers-color-scheme:dark)");
function getSystemColorScheme(x) {
let iconImage = document.getElementsByClassName("myExternalLinkIcon")
if (x.matches) {
$iconUrl = "/img/Icon-External-link_light.png"
for (let i = 0; i < iconImage.length; i++)
iconImage[i].innerHTML = `<img src="${iconUrl}"`;
} else {
$iconUrl = "/img/Icon-External-link.png"
for (let i = 0; i < iconImage.length; i++)
iconImage[i].innerHTML = `<img src="${iconUrl}"`;
}
}
getSystemColorScheme(defaultSystemMode);
window.matchMedia("(prefers-color-scheme:dark)")
.addEventListener('change',
function(e) {
getSystemColorScheme(defaultSystemMode);
})
This way, the icon is set correctly when the page renders and also whenever the OS theme changes.
One thing to note about the Javascript is that the getSystemColorScheme()
function first finds all elements with the classname myExternalLinkIcon
. The following is an example of how that might be implemented in a <span>
tag:
<span class="myExternalLinkIcon" style="margin-left:-2px"> </span>
Serving different HTML content based on OS Theme using Hugo Shortcodes
In Hugo, you define a shortcode using Go Templates. This article won’t cover everything about shortcodes, but they are the way to implement custom re-usable components in Hugo sites.
In the case described in this article (regarding internal vs. external links), I wrote a custom shortcode for “linking out” to another site. In Hugo, shortcodes are usually stored in themes/<name>/layouts/shortcodes/
as HTML files.
My particular shortcode, linkout
, is called in anywhere in the markdown files like this:
{{<linkout href="https://github.com/prarsena">}}GitHub{{</linkout>}}
This shortcode (themes/<name>/layouts/shortcodes/linkout.html
) combines Go templates for variables, HTML, and Javascript, which reads the condition of the @media query. I’m not the greatest Go programmer (go-grammer?), but here’s the sample code and I’ll try to explain in-line:
/*
Define script variables:
$ref is the href (link destination).
$iconUrl is the default location of the image.
*/
{{ $ref := "" }}
{{ $iconUrl := "/img/Icon-External-link.png"}}
/*
Set the value of href to the link definition.
linkout includes an href attribute, which
this value refers to.
*/
{{ with .Get "href" }}
{{ $ref = . }}
{{ end }}
/*
Create the <a> tag and use Go Template variables
to insert the $ref variable into the href attribute.
Open link in new window, and apply a custom styling class.
*/
<a {{ with $ref }} href="{{.}}" {{ end }}
target="_blank" rel="noopener" class="newlinkflow">
/*
The link text is whatever is written between
the opening and closing linkout tags.
*/
{{ .Inner | .Page.RenderString }}
/*
Create a <span> next to the link text.
Insert an image in-line with the value of
the $iconUrl variable as the src.
*/
<span class="myExternalLinkIcon" style="margin-left:-2px">
<img {{ with $iconUrl }} src="{{.}}" {{ end }}>
</span>
</a>
/*
Write the Javascript to check and monitor
the preferred color scheme.
Uses Go Template variables to set the $iconUrl,
rather than pure Javascript.
*/
<script>
defaultSystemMode = window.matchMedia("(prefers-color-scheme:dark)");
function getSystemColorScheme(x) {
iconImage = document.getElementsByClassName("myExternalLinkIcon")
if (x.matches) {
{{ $iconUrl = "/img/Icon-External-link_light.png" }}
for (let i = 0; i < iconImage.length; i++)
iconImage[i].innerHTML = '<img {{ with $iconUrl }} src="{{.}}" {{ end }} >';
} else {
{{ $iconUrl = "/img/Icon-External-link.png" }}
for (let i = 0; i < iconImage.length; i++)
iconImage[i].innerHTML = '<img {{ with $iconUrl }} src="{{.}}" {{ end }} >';
}
}
getSystemColorScheme(defaultSystemMode);
window.matchMedia("(prefers-color-scheme:dark)")
.addEventListener('change',
function(e) {
getSystemColorScheme(defaultSystemMode);
})
</script>
Custom CSS class for the shortcode
In the shortcode definition (linkout.html
), the tags were assigned the class newlinkflow
. I wanted to mention that these custom styles are defined in themes/<name>/assets/_shortcodes.scss
.
In this case, the CSS class looks like the following:
a.newlinkflow {
color: var(--color-link);
padding: 2px 1px;
cursor: pointer;
&:hover {
text-decoration: none;
background-color: var(--color-link);
color: white;
}
}
Notice, of course, that it makes use of the --color-link
variable, which is determined by the preferred color theme of the device.