Dark Mode Extension
Implement a Dark Mode extension with the Scandi Plugin Mechanism!
Last updated
Implement a Dark Mode extension with the Scandi Plugin Mechanism!
Last updated
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
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.
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:
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:
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.
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.
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.
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.
The Reducer is the part that determines how the Redux store should be updated in response to actions.
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.
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:
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.
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:
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 |
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.
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
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.
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.
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:
Create a component that covers the page with a color-inverting overlay if Dark Mode is enabled
Create a plugin that would render this component on the page
Make some adjustments to fix colors that are broken as a result of Dark Mode
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:
Now, let's create a plugin that wraps the entire application in a DarkModeProvider
. We can do this by plugging into the renderRouter
function of the App
component β the entire application is rendered inside this.
The DarkModeProvider
component 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:
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.
To fix incorrect colors, we find the CSS variables responsible for incorrectly colored elements, and we invert their hues whenever dark mode is enabled:
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)
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:
Now, in the stylesheet, all we need to do is invert the colors:
Now, images look good regardless if dark mode is enabled:
Optional exercises you can complete to make sure you have understood the code:
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.
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
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!