Dark Mode Extension

Implement a Dark Mode extension with the Scandi Plugin Mechanism!

One of the most powerful features of Scandi is its Plugin Mechanism, giving extensions virtually unlimited possibilities to alter the theme's behavior. In this tutorial, we will be using the Plugin Mechanism to implement an example extension that will allow the user to switch to a dark theme.

What you will learn:

  • Writing Scandi plugins

  • Creating and styling new components

  • Working with Redux and browser local storage

  • CSS variables

  • Inverting the colors of a web app

  • Scandi extension developing practices

Prerequisites

For this tutorial, you will need to have a Scandi theme set up and running. If you don't, you can set it up in minutes by using the create-scandipwa-app (CSA) script. There is no need for a local Magento instance as long as you have an internet connection.

Before you learn to develop with Scandi, you need to have a basic understanding of JavaScript, a scripting language for the web. The MDN developer docs are a great resource for JavaScript documentation. You should also be familiar with React, the UI library that Scandi uses. You don't need to read all of this documentation right now, but this is a great place to start if you get lost in code.

Create a New Extension

The first thing we need to do to get started is creating an extension. An extension is a reusable package that can be installed on any Scandi theme. Once you are done with this tutorial, you will be able to use this extension in other projects, as long as their version is compatible — and even share it with others!

To create an extension, navigate to the root of your CSA application in the terminal. You can create a new extension using a scandipwa script:

scandipwa extension create scandi-dark-theme

If you haven't installed the Scandi CLI script, you can do so with npm i -g scandipwa-cli

This script will initialize a new extension named packages/scandi-dark-theme and configure your theme to install it. It will also enable it by setting scandipwa.extensions["scandi-dark-theme"] to true in package.json.

We should now verify that the extension is working properly. For testing purposes, we will create a plugin that simply logs something to the console. In the src/plugin directory of your extension, create a file named Header.component.plugin.js with the following contents:

src/plugin/Header.component.plugin.js
export const testPlugin = (args, callback, instance) => {
console.log("Extension is working!");
return callback(...args);
};
export default {
"Component/Header/Component": {
"member-function": {
render: testPlugin,
},
},
};

Above, we define a plug-in testPlugin that logs a message to the console before passing control to the callback function. Once the callback function returns, we return the value it produces.

We then export a configuration that specifies that this plugin should be used for the render method of the class with the namespace Component/Header/Component.

The plugin mechanism will wrap the render method of the Header component with our custom plugin - whenever render is called, our plugin will be used instead. However, we don't want to alter the value returned by render, so we must call callback (which represents the original render function, possibly wrapped in other plugins) and pass on its return value. If this still seems confusing, feel free to refer to the plugin documentation.

Now, whenever the render method of the Header component will be called, our message should appear in the console. And indeed it does! You might have to restart your app for the plugin to be registered.

Define a New Redux Store

We want the user to be able to enable or disable dark mode, so we need a way for our application to keep track of whether Dark Mode is turned on. Since this state is global to the entire application, the best place to put it is in a Redux store.

Redux is a global state container library. Scandi uses Redux to keep track of its global state and has certain conventions for how Redux should be used.

Create a new store called DarkMode. When you have created the necessary boilerplate for the Redux store, we will create an action for it, and implement the reducer. Then, we will register the reducer in the global store.

The quickest way to create a new store in VSCode is with the ScandiPWA Development Toolkit add-on. Open your extension's directory in a new window - then press Ctrl+Shift+P to open the command pop-up and search for the ScandiPWA: Create a store command.

Redux Action

In our Redux store, DarkMode.action.js should contain a function for creating actions. In Redux terminology, an action is a simple JavaScript object that describes a state update (but doesn't do anything itself).

In our case, we need an action creator for enabling or disabling Dark Mode.

src/store/DarkMode/DarkMode.action.js
export const DARKMODE_ENABLE = 'DARKMODE_ENABLE';
/** @namespace ScandiDarkTheme/Store/DarkMode/Action/enableDarkMode */
export const enableDarkMode = (enabled) => ({
type: DARKMODE_ENABLE,
enabled
});

Nothing complicated here – enableDarkMode(true) returns { type: 'DARKMODE_ENABLE', enable: true }, and enableDarkMode(false) returns { type: 'DARKMODE_ENABLE', enable: false }. These Redux Actions are simple objects that don't do anything until we write code that interprets their meaning and updates the store, called reducers.

Redux Reducer

The Reducer is the part that determines how the Redux store should be updated in response to actions.

src/store/DarkMode/DarkMode.reducer.js
import { DARKMODE_ENABLE } from './DarkMode.action';
/** @namespace ScandiDarkTheme/Store/DarkMode/Reducer/getInitialState */
export const getInitialState = () => ({
enabled: false
});
/** @namespace ScandiDarkTheme/Store/DarkMode/Reducer/DarkModeReducer */
export const DarkModeReducer = (state = getInitialState(), action) => {
switch (action.type) {
case DARKMODE_ENABLE:
const { enabled } = action;
return {
enabled
};
default:
return state;
}
};
export default DarkModeReducer;

Our reducer maintains a single field in its state, enabled. Whenever it receives a DARKMODE_SET-type action, it returns (updates) the state with a new enabled value.

Note that this function will be called by Redux. Our only responsibility is to define how the state should update.

getStaticReducers Plug-in

We have defined DarkModeReducer, but, like any function, it doesn't do anything until it's called. Reducer functions should be managed by Redux and some core Scandi code.

All the existing Reducers are registered in store/index.js, in the function getStaticReducers. We can register our reducer by writing a plug-in for this function:

src/plugin/getStaticReducers.plugin.js
import DarkModeReducer from "../store/DarkMode/DarkMode.reducer";
export const getStaticReducers = (args, callback) => ({
...callback(args),
DarkModeReducer,
});
export default {
"Store/Index/getReducers": {
function: getStaticReducers,
},
};

Now, the reducer should be registered. You can check with the Redux DevTools extension for Chrome or Firefox that there is now a DarkModeReducer in the store. Next, we'll need a way for the user to change the value in this Redux store.

Render a Dark Mode Toggle

We already wrote a testPlugin for the Header component that technically works, but doesn't do much. Instead of logging to the console, we want to render a toggle button for enabling dark mode:

src/plugin/Header.component.plugin.js
import ModeToggleButton from "../component/ModeToggleButton";
import "./Header.style.plugin";
export const renderTopMenu = (args, callback, instance) => {
return (
<>
{callback(...args)}
<div block="Header" elem="DarkModeToggle">
<ModeToggleButton />
</div>
</>
);
};
export default {
"Component/Header/Component": {
"member-function": {
renderTopMenu,
},
},
};

This code will render a ModeToggleButton right after the top menu. However, for this to work, we will also have to define the ModeToggleButton – otherwise, our plugin will attempt to render a non-existent component.

How can we find the namespace to plug in to? This can be achieved by using React Developer Tools - a browser extension that allows you to inspect the rendered React elements. I knew that I wanted to render the button at the top of the page, so I checked which element renders it. Once I had the name of the element (Header), I could easily search for it in the codebase and find the corresponding namespace.

You can create a new component in VSCode with the ScandiPWA Development Toolkit add-on by using the ScandiPWA: Create a component command. Enable the "connected to the global state" option.

When you've created the ModeToggleButton component, you will see that it contains several files:

File

ModeToggleButton.container.js

Contains business logic. Here we will define how the component should enable or disable dark mode

ModeToggleButton.component.js

Responsible for rendering a UI. Here, we will output the UI components (in this case, a button) and define their interactions

ModeToggleButton.style.scss

A stylesheet for our component

index.js

Aliases the .container file

The Container

Containers are for business logic. In our case, that means connecting to the Redux store to provide the current DarkMode state (enabled or disabled), and a function to dispatch actions to update the state. This will "connect" it to the Redux store we created in the previous section.

src/component/ModeToggleButton/ModeToggleButton.container.js
import { connect } from "react-redux";
import { enableDarkMode } from "../../store/DarkMode/DarkMode.action";
import ModeToggleButton from "./ModeToggleButton.component";
/** @namespace ScandiDarkTheme/Component/ModeToggleButton/Container/mapStateToProps */
export const mapStateToProps = (state) => ({
isDarkModeEnabled: state.DarkModeReducer.enabled,
});
/** @namespace ScandiDarkTheme/Component/ModeToggleButton/Container/mapDispatchToProps */
export const mapDispatchToProps = (dispatch) => ({
enableDarkMode: (enabled) => dispatch(enableDarkMode(enabled)),
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(ModeToggleButton);

mapStateToProps has access to the Redux store - we want the component to get isDarkModeEnabled as a prop. mapDispatchToProps is connected to the Redux dispatcher - by dispatching enableDarkMode, we can now enable or disable the dark mode configuration in the Redux store.

The Component

The .component file is responsible for rendering the user interface. In this case, we render a simple button – and when it's clicked, we toggle the Dark Mode Setting.

src/component/ModeToggleButton/ModeToggleButton.component.js
import PropTypes from "prop-types";
import { PureComponent } from "react";
import "./ModeToggleButton.style";
/** @namespace ScandiDarkTheme/Component/ModeToggleButton/Component/ModeToggleButtonComponent */
export class ModeToggleButtonComponent extends PureComponent {
static propTypes = {
isDarkModeEnabled: PropTypes.bool.isRequired,
enableDarkMode: PropTypes.func.isRequired,
};
render() {
const { isDarkModeEnabled, enableDarkMode } = this.props;
return (
<button
block="ModeToggleButton"
aria-label={ __("Toggle Dark Mode") }
onClick={() => enableDarkMode(!isDarkModeEnabled)}
>
{ __("Toggle Dark Mode") }
</button>
);
}
}
export default ModeToggleButtonComponent;

Now we have a button that toggles the state in our Dark Mode Redux store (you can check this with the Redux DevTools). Next, we need to implement a component that will read from this state and use a dark Scandi theme dark if Dark Mode is enabled.

Implementing Dark Mode

There are several ways we can implement dark mode:

  • Adjusting the values of all theme colors using CSS variables

  • Using the filter property to invert the brightness of the entire app

  • Using an all-white overlay with the difference blending mode, resulting in inverted colors

Adjusting CSS variables would be a neat solution, and it would give us control over each color individually. However, in Scandi, many color values do not use the theme variables but are instead hardcoded. This limits how much control we can have on the app's colors via CSS variables, so this technique wouldn't work.

Another approach would be setting filter: invert() hue-rotate(180deg) on the root HTML element to invert the brightness, but keep the same hue for all colors. This would be an elegant solution, but after experimenting with it I noticed that, even though it worked well in Chromium, it can cause layout bugs in Firefox:

After some testing, I concluded the last method — using an overlay with a blending mode — works well in Scandi, so is what we'll be using for the purposes of this tutorial. It feels a bit "hacky" but unlike the other techniques, it works.

This is how we will implement it:

  1. Create a component that covers the page with a color-inverting overlay if Dark Mode is enabled

  2. Create a plugin that would render this component on the page

  3. Make some adjustments to fix colors that are broken as a result of Dark Mode

Wrapping the App in a DarkModeProvider

First, we create a new component responsible for implementing dark mode, called DarkModeProvider. Like the dark mode toggle button, this component needs access to the dark mode configuration. However, it should render something different:

src/component/DarkModeProvider/DarkModeProvider.component.js
// [...]
render() {
const { children, isDarkModeEnabled } = this.props;
// we specify a modifier called `isEnabled` in the `mods` prop
// if isDarkModeEnabled is true, the modifier will be added, otherwise not
return (
<div block="DarkModeProvider" mods={{ isEnabled: isDarkModeEnabled }}>
{children}
</div>
);
}
// [...]

This code makes use of the Block-Element-Modifier (BEM) methodology. This is a set of guidelines for formatting CSS classes so that components can be easily styled, composed, and maintained.

In this example, the block is "DarkModeProvider" and the element has 1 modifier: isEnabled, which is either true or false. If it is false, the modifier does not get added. If it is true, the class gets an additional modifier: DarkModeProvider_isEnabled. We will be using this class selector in CSS, to ensure that dark mode is only active when the modifier is added:

src/component/DarkModeProvider/DarkModeProvider.style.scss
.DarkModeProvider {
// the ::after pseudo-element is what we use to invert all of the colors
&::after {
// by default (when dark mode is off), we don't want it to be visible
// so we set the opacity to 0.
// it is overridden with opacity: 1 in .DarkModeProvider_isEnabled::after
opacity: 0;
// defines a smooth transition when enabling or disabling dark mode
transition: opacity ease-out 100ms;
content: ""; // needed for ::after to be rendered at all
// 1. make sure the element covers the entire page
display: block;
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
// 2. make sure the element is white, and "above" all the other layers
z-index: 99999;
background-color: white;
// 3. magic. by using the difference blending mode with a white color,
// all the colors in the app become inverted.
// this works in all modern browsers.
mix-blend-mode: difference;
// we want click events to "pass through" this element,
// so that it wouldn't interfere with the colors of the app
pointer-events: none;
}
// styles that are only applied if dark mode is enabled
&_isEnabled {
&::after {
// makes the inverting ::after element (from above) visible
opacity: 1;
}
}
}

Now, our dark mode turns the theme dark, as expected. However, there are still some issues. As you might notice, all of the images appear inverted. In addition, all of the colors are inverted as well. Our next steps will be to fix these issues.

Color Adjustments

To fix incorrect colors, we find the CSS variables responsible for incorrectly colored elements, and we invert their hues whenever dark mode is enabled:

src/component/DarkModeProvider/DarkModeProvider.style.scss
// [...]
&_isEnabled {
// adjust-hue is a SCSS function that "rotates" the hue of a specific color
// in this case, we use it to create complementary colors of the same brightness
--primary-error-color: #{adjust-hue(#dc6d6d, 180deg)};
--primary-success-color: #{adjust-hue(#7fcd91, 180deg)};
--primary-info-color: #{adjust-hue(#ffd166, 180deg)};
--primary-base-color: var(
--imported_primary_base_color,
#{adjust-hue($default-primary-base-color, 180deg)}
);
--primary-dark-color: var(
--imported_primary_dark_color,
#{adjust-hue($default-primary-dark-color, 180deg)}
);
--primary-light-color: var(
--imported_primary_light_color,
#{adjust-hue($default-primary-light-color, 180deg)}
);
--secondary-base-color: var(
--imported_secondary_base_color,
#{adjust-hue($default-secondary-base-color, 180deg)}
);
--secondary-dark-color: var(
--imported_secondary_dark_color,
#{adjust-hue($default-secondary-dark-color, 180deg)}
);
--secondary-light-color: var(
--imported_secondary_light_color,
#{adjust-hue($default-secondary-light-color, 180deg)}
);
--link-color: var(--primary-base-color);
--cart-overlay-totals-background: var(--secondary-base-color);
--overlay-desktop-border-color: var(--primary-light-color);
--menu-item-figure-background: var(--secondary-base-color);
--menu-item-hover-color: var(--primary-base-color);
--newsletter-subscription-placeholder-color: var(--secondary-dark-color);
--newsletter-subscription-button-background: var(--link-color);
--button-background: var(--primary-base-color);
--button-border: var(--primary-base-color);
--button-hover-background: var(--primary-dark-color);
--button-hover-border: var(--primary-base-color);

Re-inverting Images

To fix image appearance, we want to re-invert all images so that they appear normal when the entire page is inverted.

We plug into the render method of the Image component to wrap its contents in a ColorInverter component (which we haven't yet defined)

src/plugin/Image.component.plugin.js
// wraps the output of the Image.render function in our ColorInverter component
export const render = (args, callback, instance) => {
return <ColorInverter>{callback(...args)}</ColorInverter>;
};
// export a configuration specifying the namespace we want to plug in to
// as well as the type of plugin
export default {
"Component/Image/Component": {
"member-function": {
render,
},
},
};

The ColorInverter component is very similar to our existing DarkModeProvider component - it inverts the colors of its child elements. The difference is that ColorInverter can use the filter property without causing bugs to invert the colors.

The container file is exactly the same as the one for DarkModeProvider (except for the different component name) — all it needs to is to provide the current Dark Mode state to the component.

The component file is also similar:

src/component/ColorInverter/ColorInverter.component.js
// [...]
export class ColorInverterComponent extends PureComponent {
static propTypes = {
isDarkModeEnabled: PropTypes.bool.isRequired,
children: ChildrenType.isRequired,
};
render() {
const { isDarkModeEnabled, children } = this.props;
// we specify a modifier called `isInverted` in the `mods` prop
// if isDarkModeEnabled is true, the modifier will be added, otherwise not
return (
<div block="ColorInverter" mods={{ isInverted: isDarkModeEnabled }}>
{children}
</div>
);
}
}
// [...]

Now, in the stylesheet, all we need to do is invert the colors:

src/component/ColorInverter/ColorInverter.style.scss
.ColorInverter {
filter: invert(0);
transition: filter ease-out 100ms;
// these styles will only apply to elements whose Block is "ColorInverter"
// and that have the { isInverted: true } prop
// the corresponding CSS class for these elements is .ColorInverter_isInverted
&_isInverted {
filter: invert(1);
}
}

Now, images look good regardless if dark mode is enabled:

Exercises

Optional exercises you can complete to make sure you have understood the code:

  1. We fixed product images, but configurable product color options are still inverted. Override the ProductCard and ProductAttributeValue components to fix these colors in PLP and PDP.

  2. The dark mode toggle button can be distracting. Instead of rendering it at the top of the page, put it in the My Account page, in the Dashboard section.

The code produced as part of this tutorial is available here

What Next?

Now that you have created your extension, you can use it on any of your projects, or publish it to share it with others. We hope this tutorial was useful for learning the principles of Scandi plugin development, and can't wait to see what you will create!

Written by Reinis Mazeiks. Feel free to ask questions and share feedback in the Slack channel. Thanks!