Setting up SCSS themes and contexts with NextJs or React
There are two solutions:
- You can use native CSS variables
- Or use two sets of SCSS variables and set up a Provider with React Context
Table of Contents
The Simple Solution
One might be tempted to use CSS variables. Indeed, you can, but they are not very practical and do not always work with SCSS functions like darken()
or lighten()
. So, if you are not using any SCSS functions in your project, why not.
The syntax is very simple, in your default.scss file:
:root {
--var1: #000,
--var2: #fff
}
Using them is also very straightforward:
div {
background-color: var(--var1);
color: var(--var2);
}
To learn more about native CSS variables: How can you use CSS variables to make everything more dynamic?
Then, simply modify them using a button with the JS function document.documentElement.style.setProperty('--var1', '#262626');
Now, let's look at the second solution
You can also use a theme provider
. A component like the auth provider
which will be placed as high as possible in the component hierarchy to apply to the entire project.
Let's start with our sets of variables. We will create the same variables twice, but with different values for each theme. To do this, we will create a themes
variable that will contain all the others for their respective themes.
$themes: (
"dark": (
var1: red,
bg: #1f1f1f,
text: #fff,
),
"light": (
var1: blue,
bg: #fff,
text: #000,
),
);
To use them, you can do this:
.theme--light {
div {
background-color: map-get(map-get($themes, "light"), "bg");
}
}
.theme--dark {
div {
background-color: map-get(map-get($themes, "dark"), "bg");
}
}
It's not very practical, nor very dynamic. We want to avoid having to do this every time. We need to adapt.
We will create a mixin
that applies the dark
or light
theme based on what we choose using the context
, which we will use just after. It injects the theme variables (for light
or dark
) dynamically into the CSS.
@mixin themed() {
@each $theme, $map in $themes {
.theme-#{$theme} & {
$theme-map: () !global;
@each $key, $submap in $map {
$value: map-get(map-get($themes, $theme), "#{$key}");
$theme-map: map-merge(
$theme-map,
(
$key: $value,
)
) !global;
}
/* execute the original content of the mixin */
@content;
/* remove the global variable */
$theme-map: null !global;
}
}
}
@each $theme, $map in $themes {
Here, the declared variable $theme
represents the name from $themes
, namely light and dark. $map
represents the map associated with each theme, the specific key-
value
pairs for that theme (e.g., var1: red).
.theme--#{$theme} & {
Above, we define the CSS selector in the form of .theme-themeName.
$theme-map: () !global;
We declare here the global variable $theme-map
initialized as an empty map.
@each $key, $submap in $map {
Here, we loop again through the map (for light
or dark
). $key
is the name of the variable, and $submap
is the value (we won't use it, e.g., key: submap).
$value: map-get(map-get($themes, $theme), "#{$key}");
$theme-map: map-merge(
$theme-map,
(
$key: $value,
)
) !global;
For each key, its value is retrieved and added to $theme-map
using map-merge.
@content;
$theme-map: null !global;
Here, @content
allows executing the content of the mixin, then we remove the global variable $theme-map
.
@function t($key: "var0") {
@return map-get($theme-map, $key);
}
This t
function, with the default value var0, allows retrieving a key ($key
) from its respective map ($theme-map
), for example, the text key in our dark map. Note that $theme-map
is a global variable.
The complete SCSS code is therefore:
@mixin themed() {
@each $theme, $map in $themes {
.theme-#{$theme} & {
$theme-map: () !global;
@each $key, $submap in $map {
$value: map-get(map-get($themes, $theme), "#{$key}");
$theme-map: map-merge(
$theme-map,
(
$key: $value,
)
) !global;
}
@content;
$theme-map: null !global;
}
}
}
@function t($key: "base0") {
@return map-get($theme-map, $key);
}
One small particularity if you are using CSS modules (file.module.scss), you need to recreate the themed()
function, but add the :global()
expression, keeping the previous one as well:
// ↓↓↓ here is the only difference
:global(.theme-#{$theme}) & {
[…]
}
.theme-#{$theme} & {
[…]
}
In this case, we will use themed()
in modular SCSS files, and themedGlobal()
in basic SCSS files.
- If you use
themed()
in a .scss file, you will get an error. - If you use
themedGlobal()
in a .module.scss file, the corresponding style will not be applied.
To apply it, simply use @include
for the SCSS key to use:
div {
@include themed {
background-color: t("bg");
}
}
Setting Up the Provider
A provider will allow us to link our JS to our SCSS, in a way. It's the least fun part. We will need to use React Context to make the theme choice accessible.
Your
_app
file must be in.tsx
and not in.js
, just like the file we are about to create now =>ThemeContext.tsx
. Ideally, store it in the contexts folder, alongside the pages folder.
The file looks like this:
import React, { createContext, useState, ReactNode, useEffect } from "react"
type ContextType = {
toggleDark: () => void
isDark: boolean
};
const defaultContext: ContextType = {
toggleDark: () => {
console.warn("Should have been overridden")
},
isDark: false,
};
const ThemeContext = createContext(defaultContext)
export const ThemeContextProvider = ({ children }: { children: ReactNode }) => {
const [isDark, setIsDark] = useState(true)
const context: ContextType = {
toggleDark: () => {
setIsDark(!isDark)
},
isDark,
};
return (
<ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
);
};
export default ThemeContext
Let's Try to Understand It Better
import React, { createContext, useState, ReactNode } from "react"
First, we import useState, which you are likely familiar with, as well as:
- createContext: allows creating a context object. This lets you share values between components without having to explicitly pass them through every level of the component tree.
- ReactNode: specific to TypeScript, it represents anything that can be rendered by React (elements, strings, booleans...). It's very useful for typing the
children
props of components, ensuring they accept valid React children.
Defining the Context Type
type ContextType = {
toggleDark: () => void
isDark: boolean
};
We specify the structure of the data stored in the context. It helps us declare:
- toggleDark function: it toggles between dark and light themes
- isDark: a boolean that indicates whether the theme is in dark mode
Default Context
const defaultContext: ContextType = {
toggleDark: () => {
console.warn("Should have been overridden")
},
isDark: false,
};
This provides default values for the context:
- toggleDark: a warning indicating that the function should be overridden
- isDark: by default, false
Creating the Context
const ThemeContext = createContext(defaultContext);
We create the context called ThemeContext
, with the default values from defaultContext
.
Context Provider, the Provider
export const ThemeContextProvider = ({ children }: { children: ReactNode }) => {
const [isDark, setIsDark] = useState(false)
const context: ContextType = {
toggleDark: () => {
setIsDark(!isDark)
},
isDark,
};
return (
<ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
);
};
This is a component where we manage the value of isDark
with useState()
.
We also have the context object, context
.
- toggleDark: the finalized version, which toggles the value of
isDark
from false to true and vice versa - isDark: represents whether the theme is dark or light
Finally, we return ThemeContext.Provider
, with the context
as the value, wrapping the children passed to ThemeContextProvider
.
- ThemeContext: created earlier
- Provider: React element that allows passing context values to child components
- value attribute: takes a value accessible by all child components within the context (here, it contains
isDark
andtoggleDark
)
export default ThemeContext;
We then export the context by default to use it elsewhere.
Implementing the Provider
In our _app.tsx
file, we will wrap the app with our Provider
, similar to how you would with NextAuth authentication. Don't forget to add a div that contains the famous theme--themeName
class. It's thanks to this that other elements will receive the dark or light styles.
<ThemeContextProvider>
<div className={`${isDark ? "theme-dark" : "theme-light"}`}>
<Layout>
<Component {...pageProps} />
</Layout>
</div>
</ThemeContextProvider>
To ensure everything works, import AppProps
, to add it after Component
and pageProps
, like this:
function MyApp({ Component, pageProps }: AppProps) {
The Magic Button
Now we will create a button that will execute the toggleDark
function.
<button onClick={toggleDark}>
{isDark ?
Switch to Light Theme
: Switch to Dark Theme
}
</button>
Don't forget to import this function in the same file
import React, { useContext } from 'react'
import ThemeContext from '../../../context/ThemeContext.tsx'
const yourComponentOrPage = () => {
const { isDark, toggleDark } = useContext(ThemeContext)
return (
<button onClick={toggleDark}>
{isDark ?
Switch to Light Theme
: Switch to Dark Theme
}
</button>
)
}
export default yourComponentOrPage
Applying the Theme to the Body
We cannot really apply a class based on the value of a context to an element as high as body. Indeed, it is located in the document.js
file, which is above our provider, in _app.tsx
. To fix this, we can use what we did at the very beginning: the less practical version, without a provider, but just for the body element, like this:
body {
.theme--dark {
background-color: map-get(map-get($themes, "dark"), "bg");
}
.theme--light {
background-color: map-get(map-get($themes, "light"), "bg");
}
}
Additionally, you need to manually apply the theme--XXXX class to the body in the _app.tsx
file, like this:
useEffect(() => {
document.querySelector('body').className = isDark ? 'theme-dark' : 'theme-light'
}, [isDark]);
Make sure to place this useEffect
below the declaration of the isDark
variable.
With this, the problem is fixed.
Detecting User Preference
We detect the user's preferred theme with matchMedia
, by adding a condition in our Provider
. So, we add a useEffect in ThemeContextProvider
, right between the useState isDark and the context
:
useEffect(() => {
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setIsDark(true)
}
}, []);
Here, we apply the user's preferred theme, which we retrieve with window.matchMedia('(prefers-color-scheme: dark)').matches
.
With Local Storage
You'll notice that we can also use localStorage in the useEffect to check if the user has manually changed the theme, and in the ContextType
, instead of a simple variable that doesn't retain its value on reload. In this case, we would use getItem()
in the useEffect, and setItem()
in the context. Learn more about localStorage with Next
Example of retrieving the style, still in ThemeContextProvider
:
useEffect(() => {
const isItDark = JSON.parse(localStorage.getItem('ThemeIsDark'));
if (isItDark !== undefined && isItDark !== null) {
setIsDark(isItDark)
}
else if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setIsDark(true)
}
}, [])
Here, we first retrieve the theme value from localStorage, then check if it exists; if so, we apply it, otherwise, we apply the user's preferred theme.
And in the context:
const context: ContextType = {
toggleDark: () => {
localStorage.setItem('ThemeIsDark', String(!isDark))
setIsDark(!isDark)
},
isDark,
};
Here, we send the value of the inverse of isDark
to localStorage, and also modify this same variable.
Resolving Bugs
An import path cannot with a '.tsx' extension
This error might be caused by your TS version. Try adding the property "allowImportingTsExtensions": true
to your tsconfig.json file.
Unknown compiler option 'allowImportingTsExtensions'
This error is caused by your TS version. This expression did not exist before, you need to install the latest version by running npm i typescript@next
Incompatibilities
code ERESOLVE
npm ERR! ERESOLVE could not resolve
npm ERR!
npm ERR! While resolving: eslint-config-next@12.1.0
npm ERR! Found: typescript@5.6.0-dev.20240805
npm ERR! node_modules/typescript
npm ERR! typescript@"^5.6.0-dev.20240805" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peerOptional typescript@">=3.3.1" from eslint-config-next@12.1.0
npm ERR! node_modules/eslint-config-next
npm ERR! dev eslint-config-next@"12.1.0" from the root project
npm ERR!
npm ERR! Conflicting peer dependency: typescript@5.5.4
npm ERR! node_modules/typescript
npm ERR! node_modules/typescript
npm ERR! peerOptional typescript@">=3.3.1" from eslint-config-next@12.1.0
npm ERR! node_modules/eslint-config-next
npm ERR! dev eslint-config-next@"12.1.0" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
If you added TS during your project, you might encounter an error like this when trying to install another library. To resolve it, simply update your eslint-config-next
versions. To do this, use the command npm install eslint-config-next@latest -f
. Try also npm i @types/react -f
Don't forget to also update TS.
If the problem persists, try downgrading their versions, otherwise, ask your question in the comments, or contact me.
Conclusion
I admit, it's not obvious to set up, but once it's done, the result is very good. However, if you already have your SCSS variables applied to elements, you will indeed need to replace all of them with the use of the mixin with @include
. It's tedious, and I've experienced it myself. But you can't modify an SCSS variable directly, so it's one of the only solutions, sign a petition! So, for your next project, don't forget to implement this from the start, before you begin! Ideally, have an empty project with only the provider and basic elements, which you can copy to each new project to avoid having to redo it every time. Use a GitHub repo, for example.