Mettre en place des thèmes en SCSS avec NextJs ou React et les contextes
Il y a deux solutions:
- On peut utiliser les variables css natives
- Ou utiliser deux jeux de variables scss et mettre en place un Provider avec React Context
Sommaire
La solution simple
On pourrait être tenté d'utiliser les variables css. On peut en effet, mais elles ne sont pas vraiment pratiques, et ne fonctionnent pas toujours avec les fonctions scss telles que darken()
ou lighten()
. Alors, si vous n'utilisez aucune fonction scss durant votre projet, pourquoi pas.
La syntaxe est très simple, dans votre fichier default.scss:
:root {
--var1: #000,
--var2: #fff
}
L'utilisation est également très simple:
div {
background-color: var(--var1);
color: var(--var2);
}
Pour aller plus loin avec les variables natives css: Comment utiliser les variables CSS pour tout dynamiser ?
Il suffit ensuite de les modifier grâce à un bouton, avec la fonction js document.documentElement.style.setProperty('--var1', '#262626');
Maintenant, intérressons-nous à la deuxième solution
On peut également utiliser un theme provider
. Un composant comme le auth provider
qui va venir se placer le plus au possible dans la hiérarchie des balises pour s'appliquer à tout le projet.
Commençons par nos jeux de variables. On va créer deux fois les mêmes variables, mais avec des valeurs différentes pour chaque thème. Pour ce faire, on va créer une variable themes
qui contiendra toutes les autres pour leur thème respectif.
$themes: (
"dark": (
var1: red,
bg: #1f1f1f,
text: #fff,
),
"light": (
var1: blue,
bg: #fff,
text: #000,
),
);
Pour les utiliser, on peut faire ça:
.theme--light {
div {
background-color: map-get(map-get($themes, "light"), "bg");
}
}
.theme--dark {
div {
background-color: map-get(map-get($themes, "dark"), "bg");
}
}
C'est pas très pratique, ni très dynamique. On veut éviter d'avoir à faire ça à chaque fois. On va devoir s'adapter.
Nous allons créer un mixin
qui fait en sorte d'appliquer le thème dark
ou light
selon ce qu'on a choisi grâce au context
, que l'on utilisera juste après. Il permet d'injecter dynamiquement les valeurs des variables (du thème light
ou dark
) dans le 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 le contenu original du mixin
@content;
// supprime la variable globale
$theme-map: null !global;
}
}
}
@each $theme, $map in $themes {
Ici, la variable declarée $theme
représente le nom de ce qui est dans $themes
, à savoir light et dark. $map
représente la carte associé à chaque thème, les paires key-
value
spécifique à ce thème. (ex: var1: red).
.theme--#{$theme} & {
Ci-dessus, on défini le sélecteur css, sous la forme de .theme-nomDuTheme.
$theme-map: () !global;
On déclare ici la variable globale $theme-map
initialisée comme une carte vide.
@each $key, $submap in $map {
On refait ici une boucle, cette fois-ci dans la carte (celle de light
ou dark
). $key
est le nom de la variable, et $submap
est la valeur (on ne l'utilisera pas, ex: key: submap).
$value: map-get(map-get($themes, $theme), "#{$key}");
$theme-map: map-merge(
$theme-map,
(
$key: $value,
)
) !global;
Pour chaque clé, sa valeur est récupérée et ajoutée à $theme-map
, en utilisant map-merge.
@content;
$theme-map: null !global;
Ici, @content
permet d'exécuter le contenu du mixin, puis on supprime la variable globale $theme-map
.
@function t($key: "var0") {
@return map-get($theme-map, $key);
}
Cette fonction t
, avec la valeur var0 par défaut, permet récuper une clé ($key
) dans sa carte respéctive ($theme-map
). par exemple la clé text dans notre carte dark. Notez que $theme-map
est une variable globale.
Le code complet scss donne donc:
@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);
}
Petite particularité si vous utilisez les modules css (fichier.module.scss), il faut recréer la fonction themed()
, mais ajouter l'expression :global()
, en gardant aussi la précédente, sans.
// ↓↓↓ ici est la seule différence
:global(.theme-#{$theme}) & {
[…]
}
.theme-#{$theme} & {
[…]
}
Dans ce cas, on va utiliser themed()
dans les fichier scss modulaires, et themedGlobal()
dans les fichier scss basiques.
- Si vous utilisez
themed()
dans un fichier de type fichier.scss, vous obtiendrez une erreur. - Si vous utilisez
themedGlobal()
dans un fichier de type fichier.module.scss, le style correspondant ne s'appliquera pas.
Pour l'appliquer, il suffit d'utiliser @include
pour la clé scss à utiliser:
div {
@include themed {
background-color: t("bg");
}
}
Mettre en place le provider
Un provider va nous permettre de relier notre js à notre scss, en quelques sortes. C'est la partie la moins drôle. On va devoir utiliser le React Context pour rendre accessible le choix du mode.
Votre fichier
_app
devra être en.tsx
et non en.js
, tout comme le fichier que nous allons créer maintenant =>ThemeContext.tsx
. Idéalement, on le stock dans le dossier contexts, à la même place que le dossier pages.
Le fichier ressemble à ça:
import React, { createContext, useState, ReactNode, useEffect } from "react"
type ContextType = {
toggleDark: () => void
isDark: boolean
};
const defaultContext: ContextType = {
toggleDark: () => {
console.warn("Should have been overriden")
},
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
Essayons de mieux le comprendre
import React, { createContext, useState, ReactNode } from "react"
Tout d'abord, on importe useState, que vous connaissez sûrement, ainsi que:
- createContext: permet de créer un objet de contexte. Cela permet de partager des valeurs entre les composants sans avoir à passer explicitement ces dernières à travers chaque niveau de l'arboresence des composants.
- ReactNode: c'est spécifique à typescript, et cela représente tout ce qui est rendu par React (éléments, chaines de caractèresn bolléens...). C'est très utile pour typer les props
children
des composants, pour s'assurer qu'ils acceptent des enfants valides React.
Définition du type de contexte
type ContextType = {
toggleDark: () => void
isDark: boolean
};
On spécifie la norme des données stockées dans le contexte. Elle nous sert à déclarer:
- fonction toggleDark: elle bascule entre les thèmes sombre et clair
- isDark: un booléen qui permet de savoir si le thème est en mode sombre
Contexte par défaut
const defaultContext: ContextType = {
toggleDark: () => {
console.warn("Should have been overridden")
},
isDark: false,
};
Cela permet de donner des valeurs par défaut pour le contexte:
- toggleDark: un avertissement qui indique que la fonction doit être remplacé
- isDark: par défaut, false
Création du contexte
const ThemeContext = createContext(defaultContext);
On créer le contexte appelé ThemeContext
, avec comme valeurs par défaut celles de defaultContext
.
Fournisseur du contexte, le 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>
);
};
C'est un composant, dans lequel on gère la valeur de isDark
avec un useState()
.
On y trouve également l'objet de contexte, context
.
- toggleDark: la version terminée, qui switch la valeur de
isDark
de false à true et inversement - isDark: elle représente si le thème est sombre ou clair
Pour finir, on retourne ThemeContext.Provider
, avec comme valeur context
, en enveloppant les enfants passés a ThemeContextProvider
.
- ThemeContext: créé précédement
- Provider: élément React qui permet de de faire passer les valeurs de contexte aux enfants
- Attribut value: prend une valeur, accessible par tous les composants enfants du contexte (ici, elle contient
isDark
ettoggleDark
)
export default ThemeContext;
On exporte ensuite par défaut le contexte pour pouvoir l'utiliser autre part.
Implémentation du Provider
Dans notre fichier _app.tsx
, on va englober l'app par notre Provider
, comme on le ferait avec l'authentification de NextAuth. Il ne faut pas oublier d'ajouter une div qui contient la fameuse classe theme--nomDuTheme
. C'est grâce à elle que les autres élément se verrons donner le style sombre ou clair.
<ThemeContextProvider>
<div className={`${isDark ? "theme-dark" : "theme-light"}`}>
<Layout>
<Component {...pageProps} />
</Layout>
</div>
</ThemeContextProvider>
Pour être sûr que tout fonctionnera, importez AppProps
, pour l'ajouter après Component
et pageProps
, de cette manière:
function MyApp({ Component, pageProps }: AppProps) {
Le bouton magique
On va maintenant créer un bouton qui éxécutera la fonction toggleDark
.
<button onClick={toggleDark}>
{isDark ?
Passer au thème clair
: Passer au thème sombre
}
</button>
Sans oublier d'importer cette fonction dans le même fichier
import React, { useContext } from 'react'
import ThemeContext from '../../../context/ThemeContext.tsx'
const votreComposantOuPage = () => {
const { isDark, toggleDark } = useContext(ThemeContext)
return (
<button onClick={toggleDark}>
{isDark ?
Passer au thème clair
: Passer au thème sombre
}
</button>
)
}
export default votreComposantOuPage
Appliquer le thème au body
On ne peut pas tellement appliquer une classe basée sur la valeur d'un context à un élément si haut placé que body. En effet, ce dernier se trouve dans le fichier document.js
, qui est donc au-dessus de notre provider, dans _app.tsx
. Pour y remédier, on peut utiliser ce qu'on avez fait au tout début: la version pas pratique, sans provider, mais juste pour l'élément body, de cette facçon:
body {
.theme--dark {
background-color: map-get(map-get($themes, "dark"), "bg");
}
.theme--light {
background-color: map-get(map-get($themes, "light"), "bg");
}
}
De plus, il faut appliquer manuellement la classe theme--XXXX au body, dans le fichier _app.tsx,
comme ceci:
useEffect(() => {
document.querySelector('body').className = isDark ? 'theme-dark' : 'theme-light'
}, [isDark]);
Veillez à placer ce useEffect
en dessous de la déclaration de la variable isDark
Comme ça, le problème est reglé.
Détecter la préférence de l'utilisateur
On détecter le thème préféré de l'utilisateur avec matchMedia
, en ajoutant une condition dans notre Provider
. On ajoute donc un useEffect dans ThemeContextProvider
, juste entre le useState isDark et le context
:
useEffect(() => {
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setIsDark(true)
}
}, []);
Ici, on applique le thème préféré de l'utilisateur, que l'on récupère avec window.matchMedia('(prefers-color-scheme: dark)').matches
.
Avec le local storage
Vous noterez qu'on peut utiliser aussi le localStorage, dans le useEffect, pour verifier si l'utilisateur avait changé de thème manuellement, et dans le ContextType
, plutôt qu'une simple variable qui ne garde donc pas sa valeur au reload. Dans ce cas, on utiliserait getItem()
dans le useEffect, et setItem()
dans le contexte. En savoir plus sur le localStorage avec Next
Exemple de récupéraion du style, toujours dans 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)
}
}, [])
On récupère ici d'abord la valeur du thème dans le localStorage, puis on vérifie si elle existe, dans ce cas on l'applique, sinon, on applique le thème préféré de l'utilisateur.
Et dans le contexte:
const context: ContextType = {
toggleDark: () => {
localStorage.setItem('ThemeIsDark', String(!isDark))
setIsDark(!isDark)
},
isDark,
};
Ici, on envoie la valeur de l'inverse de isDark
dans le localStorage, et on modifie également cette même variable.
Pour résoudre les bugs
An import path cannot with a '.tsx' extension
Cette erreur est peut être causé par votre version de TS. Essayez d'ajouter la proprieté "allowImportingTsExtensions": true
à votre fichier tsconfig.json.
Unknown compiler option 'allowImportingTsExtensions'
Cette erreur est causée par votre version de TS. Cette expression n'existait pas avant, il faut installer la dernière version en faisant npm i typescript@next
Des imcompatibilités
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.
Si vous avez ajouté, tout comme moi, ts en plein pendant votre projet, vous risquerez de rencontrer une erreur dans ce genre quand vous voudrez installer une autre librairie. Pour la résoudre, on peut simplement updater notre versions de eslint-config-next
. Pour ça, on utilise la commande npm install eslint-config-next@latest -f
. Essayez aussi npm i @types/react -f
N'oubliez pas d'updater également TS.
Si le problème persiste, essayez de downgrade leurs versions, sinon posez votre question en commentaire, ou contactez moi.
Pour conclure
Je l'admets, c'est pas évident à mettre en place, mais une fois que c'est fait, le résultat est très bon. En revanche, si vous avez déjà vos variables scss appliquées à des éléments, il va en effet falloir toutes les remplacer par l'utilisation du mixin avec @include
. C'est fastidieux, et j'en ai moi-même fait l'expérience. Mais on ne peut pas modifier une variable scss directement, alors c'est une des seules solutions, signons une pétition ! Alors, pour votre prochain projet, n'oubliez pas d'implémenter ça directement, tout avant de commencer ! L'idéal est d'avoir un projet vide, avec uniquement le provider et les éléments basiques, que vous copierez à chaque nouveau projet, pour éviter de devoir le refaire à chaque fois. utilisez un repo github, par exemple.