Believemy logo purple

Mettre en place des thèmes en SCSS avec NextJs ou React et les contextes

On ne peut pas modifier une variable scss, car scss converti son code en css, en appliquant directement les valeurs des variables. Il est donc difficile de mettre en place un thème clair / sombre sur son projet React ou Next et scss. Heuresement, il y a des solutions, que nous allons découvrir dans cet article.
Mis à jour le 3 décembre 2024
Believemy logo

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:

CSS
:root {
    --var1: #000,
    --var2: #fff
}

L'utilisation est également très simple:

CSS
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.

CSS
$themes: (
  "dark": (
    var1: red,
    bg: #1f1f1f,
    text: #fff,
  ),
  "light": (
    var1: blue,
    bg: #fff,
    text: #000,
  ),
);

Pour les utiliser, on peut faire ça:

CSS
.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.

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;
    }
  }
}
CSS
@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).

 

CSS
.theme--#{$theme} & {

Ci-dessus, on défini le sélecteur css, sous la forme de .theme-nomDuTheme. 

 

CSS
$theme-map: () !global;

On déclare ici la variable globale $theme-map initialisée comme une carte vide.

 

CSS
@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).

 

CSS
$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.

 

CSS
@content;
$theme-map: null !global;

Ici, @contentpermet d'exécuter le contenu du mixin, puis on supprime la variable globale $theme-map.

 

CSS
@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:

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;
      }
      @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.

CSS
// ↓↓↓ 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:

CSS
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:

JAVASCRIPT
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

CSS
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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 et toggleDark)

 

JAVASCRIPT
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.

JSX
<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:

JSX
function MyApp({ Component, pageProps }: AppProps) {

 

 

Le bouton magique

On va maintenant créer un bouton qui éxécutera la fonction toggleDark.

JSX
<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

JSX
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:

CSS
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:

JSX
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:

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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

HTML
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.

Catégorie : Développement
Believemy logo
Commentaires (0)

Vous devez être connecté pour commenter cet article : se connecter ou s'inscrire.

Essayez gratuitement

Que vous essayiez de scaler votre start-up, de créer votre premier site internet ou de vous reconvertir en tant que développeur, Believemy est votre nouvelle maison. Rejoignez-nous, évoluez et construisons ensemble votre projet.

Believemy is with anyone