Setting up SCSS themes and contexts with NextJs or React

You can't modify a scss variable, because scss converts its code to css, applying the variable values directly. This makes it difficult to set up a light/dark theme on your React or Next project and scss. Fortunately, there are solutions, which we'll be exploring in this article.
•
Updated on August 14, 2026
•
12 min read
Believemy logo

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:

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

Using them is also very straightforward:

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

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

To use them, you can do this:

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");
  }
}

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.

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

 

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

Above, we define the CSS selector in the form of .theme-themeName.

 

CSS
$theme-map: () !global;

We declare here the global variable $theme-map initialized as an empty map.

 

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

 

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

 

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

Here, @content allows executing the content of the mixin, then we remove the global variable $theme-map.

 

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

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);
}

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:

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

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

JAVASCRIPT
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

CSS
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
const ThemeContext = createContext(defaultContext);

We create the context called ThemeContext, with the default values from defaultContext.

 

Context Provider, the 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>
  );
};

 

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 and toggleDark)

 

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

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

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

 

 

The Magic Button

Now we will create a button that will execute the toggleDark function.

JSX
<button onClick={toggleDark}>
           {isDark ? 
               Switch to Light Theme
              : Switch to Dark Theme
            }
 </button>

Don't forget to import this function in the same file

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

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

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

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

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)
    }
  }, [])

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:

JAVASCRIPT
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

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.

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.

Category: Developers

Share this article

Want to help us? Share this article on your networks or even better: on your site, in an article or in your newsletter.

Believemy logo
Comments (0)

You need to be logged in to comment on this article: log in or sign up.

Read next

How to use localStorage in a NextJs or React application

The aim of this article is to teach you how to use localStorage in a Next.JS or React application. Indeed, there are a few differences with the normal use, in a vanilla application.

December 11, 2024

How to install TinyMCE in a project using React or NextJS?

Find out how to install TinyMCE in React or NextJS and fully customize it with plugins to match your projects.

December 6, 2024

Why you should learn NextJS in 2025: all the benefits!

Explore why Next.js is a must-have framework for developers in 2025. Learn about its benefits, unique features and why the job market is increasingly adopting it.

December 6, 2024

Setting up a loader when changing a Nextjs page

Have you ever been on a site where you click on a link and nothing happens? Or perhaps you've wondered whether your request was actually received? This is where having a loader comes in. In this article, we'll explain why it's important to have a loader on your website, how to set it up in your Nextjs project and create a function that will display the loader until the page is loaded. If you want to improve the user experience on your website and reassure your visitors, this article is for you.

December 11, 2024

Understanding .env files with ReactJS

Find out how to really use the .env file with React or NextJS so you never make a mistake again.

December 9, 2024

What's new in NextJS 15

NextJS 15 was presented to the world on October 24, 2024 in San Francisco. Here's everything we know about NextJS 15's powerful new features!

December 5, 2024

How to use Server Actions with NextJS

Discover why and how NextJS Server Actions improve performance on both Client Components and Server Components, and learn how to use them in your projects.

December 9, 2024

Send an email with NextJS and SendGrid for free

Have you always wanted to send emails with JavaScript? Or more specifically, how to send emails with React or NextJS? Then let's find out together how to use SendGrid! It's a quick and easy solution to set up that will help us ensure that our emails are sent and, most importantly, received.

December 9, 2024

Become the solopreneur you dream of being.

First project, career change, business launch: access our courses, practice on real-world cases, and move forward with clear guidance.