Skip to content

A Guide to Theming the Open edX Platform in Teak and Beyond

The way frontend theming works has changed a lot recently and is about to radically change in the upcoming release. This document is designed to guide you through these new mechanisms.

One of the major criticisms of the way theming works with MFEs is that the theme is very tightly coupled with each MFE. Even the slightest changes to the theme requires redeploying all MFEs since each MFE is built with the theme installed and the theme is bundled into it. Additionally, users re-download the theme each time they visit each MFE, which is inefficient.

Each MFE has an installed copy of Paragon, that includes a default theme. This default theme can then customised using a branding package. The default branding package simply contains placeholder empty files that each MFE will load. In a real branding package, you can use these to change theme variables or add additional CSS as required.

When an MFE is built, it builds all the code from Paragon, the branding package and the SCSS from the MFE itself into a stylesheet for that MFE. This is why creating a common theme is hard, since each MFE has its own SCSS which needs theme variables during build time.

Recently though Paragon has done the hard work of refactoring the styling so that it can be shared across MFE and needs to only be compiled one. This has been greatly enabled by CSS Custom Properties, which is a native way in CSS to have variables. Having the variables in CSS eliminates the build step of SCSS Variables and enables the ability to change variables at runtime.

CSS Custom Properties

The ability to have variables in CSS means that now MFEs no longer need to have access to the values of variables at build time, instead they can simply reference variables that will be defined in the theme and those values can be resolved at runtime.

For example, if a component in an MFE needs access to the brand colour, it can now reference the brand colour CSS variable, which will get its value at runtime, rather than needing access to that colour at build time. With this in place you can build an MFE once, independent of the theme that will be applied to it, and it will look different depending on that theme stylesheet it is configured to load.

The latest version of Paragon splits the CSS into a core stylesheet and a theme stylesheet. The core stylesheet has all the core functional styling that provides basic structure to the components, such as the spacing, margins, offsets, fonts, borders etc. The theme stylesheet on the other will deal with mainly the colors.

One way to look at this is to consider a site with a light and dark theme. The core stylesheet has all the styling variables that would likely remain the same whether it is a light or dark theme, whereas the theme stylesheet would have colors that will change when the theme is changed.

You can build your own "brand overrides" that customise the core stylesheet or update the theme variables. This separation means that you can reuse the same core stylesheet not only across all MFEs, but also all clients or even all Open edX instances.

So with this new mechanism, the stylesheets can be:

  1. Hosted on a CDN for quicker loading
  2. Shared with all MFEs for deduplication
  3. Updated at any time without redeploying MFEs
  4. Quickly replaced at runtime
  5. Generated dynamically

In practice this means that the theme can be cached better across MFEs, reduce the amount of data downloaded for users, and be updated within seconds or minutes instead of hours.

Building the new theme stylesheet is also much faster. The final stylesheet can be compiled from design tokens (we'll cover that later) into CSS variables and uploaded to a CDN within minutes.

Since all the theme variables are isolated in a single stylesheet, you can also build mechanisms to select a theme at runtime. You can even have user-specific theme selections.

The dynamic theme generation option means that you could potentially build a UI for users to generate their own theme and have it applied at runtime.

There is minimal support for runtime theme selection and no inbuilt support for dynamic theme generation, but they are now possibilities within reach so it's important to know in case they come as client requirements.

CSS Custom Properties are a pretty cool feature that offer a lot more flexibility and functionality, then SCSS variables.

One huge benefit they bring is deduplication within CSS. Paragon has a set of common variables like colours, spacing etc that are used very broadly. So if you make a small adjustment to a single spacing or colour, the output CSS produced by the compiler will be a drastically different.

With CSS variables, this need not be the case. Since any place in CSS that needs a value can now reference the variable name, such minor changes to a variable now produce a significantly smaller diff. The separation of core and theme stylesheets means that usually these changes will be limited to only the theme stylesheet.

Another benefit of CSS variables is that, like with everything else in CSS properties can cascade.

What this means is that you can change the value of a variable depending on the component it's used in, and even the selector. To use a somewhat contrived example, if you want to change the colour of a link on hover, here is how you can do that by taking full advantage of CSS variables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
a {
  --red: 100;
  --green: 10;
  --blue: 200;
  --bg: #aabbcc;
  colour: rgb(var(--red), var(--green), var(--blue));
  background-colour: var(--bg);
}

a:hover {
  --red: 25;
  --green: 22;
  --blue: 225;
  --bg: white;
}

See how you can not only alter this colour as a whole hex value, but also each component in an rgb or rgba or even hsl function. When you hover over the above link, it will update the colours by applying the new variable values to the existing styling where the variables are referenced.

Where this is used in Paragon is for instance to have a button's core styling use a set of variables that is modified depending on the variant of the button used. So the base button can use the colour --pgn-button-colour which will be set to the correct value depending on which button variant is used.

Another great feature of CSS variables is that they can be controlled via JavaScript. A good use I can think of for this is to allow users a slider or other selection option for font size that can be updated dynamically.

Now let's move on to how these new CSS variable stylesheets are generated.

Design Tokens

Design Tokens are the latest mechanism in Paragon to build themes. They are now used extensively by Paragon and since it is a very fundamental change to the way it works, there is no backwards compatibility between the old and new way of theming. An existing branding package will not work for design tokens and will need to be rewritten.

So what are design tokens?

The concept of design tokens is simple. The key idea is to break down design decision as individual tokens that make up your entire design.

Taking the example of a simple button. What are the elements of a button? There are more than you may expect:

  • Text colour
  • Background colour
  • Border colour
  • Border curvature
  • Border thickness
  • Spacing
  • Padding
  • Font
  • Font styling

Each of these could change based on button state, for instance whether it's hovered, focused, disabled etc. You could also have variations of buttons, like primary button, secondary button, warning button etc.

The idea with design tokens is to create a token for each of these values. For instance, the x padding for a button configured with larger padding would be represented by:

1
spacing.btn.padding.x.lg

Likewise, the text colour of a primary button when it's being hovered on would be represented by:

1
colour.btn.hover.text.primary

As you can see, there is a structure to these tokens:

  • First you always start with the type of the token, colour, dimension, typography etc.
  • Second is always the component or item the token applies to, such as a button, card, alert, form etc. This may have its own hierarchy, for instance form.input for a form input component.
  • Next you have an optional state, whether it's hovered, disabled, active etc.
  • After that, you have the aspect of the component that the style will apply to. For instance, text, background, border.
  • Finally you have the variant, such as primary, secondary, warning, brand etc.

With design tokens, you create a mapping of all such tokens to their values. Consider that just the button alone has over 500 tokens! What makes this workable in the end is that all these tokens can reference other tokens, which can in turn reference more tokens.

For example, the colour of a primary button's text on hover could be set to the colour of the primary text without hover, which in turn could be set to the global theme, primary colour.

If you change the global primary colour, it would affect all components. If you change the primary colour just for buttons it would affect only buttons, and if you change the primary colour for buttons in hover state, it would only affect that.

Paragon 23 uses Amazon's Style Dictionary Library to implement design tokens and adds some additional feature that allow applying operations for lightening or darkening colours, so your button could reference a darker or lighter shade of the primary colour.

As you might be able to see, these tokens are not even web-specific, they could as easily apply to the Mobile or a desktop app. By representing these values as tokens you can then use a build process to convert these to whatever format is needed for your app in whatever platform it's on.

In Paragon, the way that these tokens are stored is using a collection of JSON files neatly organised by the component or function. You can browse them here and see all the tokens Paragon uses and how they are organised.

In the JSON files these values are stored as nested objects, for instance, the colour we mentioned above would be represented as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "colour": {
        "btn": {
            "hover": {
                "text": {
                    "primary": {
                        "$value": "#112233"
                    }
                }
            }
        }
    }
}

This is just an example snippet, you can see the full file for a more realistic example. Many of the tokens in the Paragon repo also have a description to make it easier to understand what they do.

You'll see in our example, that the nested path to the final $value represents the token. colour.btn.hover.text.primary and inside it, in addition to value, there is also source and modify.

You'll see other attributes in addition to $value. Here is a rough guide to most of them:

  • source: This usually contains the original SCSS variable name for this token before the design tokens migration. This is used by Paragon to provide tools that will automatically update source code using SCSS variables to use CSS variables instead.
  • modify: This has a list of modifications to apply to the value before being rendered to CSS. The example from the link is colour-yiq which takes the input colour value are returns the appropriate dark or light theme colour to give best contrast. This way you can set the background colour and have a text colour automatically picked to provide best contrast. You'll also see operations like lighten or darken.
  • $type: This specifies the CSS type of the value, for instance, colour, number, dimension etc.
  • $description: This is a description for this token.

JSON is quite verbose when representing deeply nested values, however the increasingly popular TOML is particularly good for this. The same JSON code we showed above could be represented in TOML as:

1
2
[colour.btn.hover.text.primary]
"$value" = "#112233"

As you can see this is a much more compact representation, and I'd recommend generally using TOML for design tokens as far as possible. We added support for it a while back, and it should work just as well as JSON.

By now, you know what design tokens are, but what do they do? What is the end result of this huge catalogue of tokens?

It's simple, each token gets compiled into its own CSS variable that is derived from the token's hierarchy. For instance, colour.btn.hover.text.primary would be outputted to a CSS variable called --pgn-colour-btn-hover-text-primary, with the correct value, which could itself be a final value or a reference to another variable.

Now that we've covered how Paragon uses tokens, let's see how you can use it as well.

Rather than coding your theme in CSS or SCSS, you can now use a similar cluster of JSON files instead. There is no enforced structure, you can organise them in any way.

When you give Paragon your tokens to compile, it produces a CSS file that overrides all the variables with the values you provide.

Developing with Design Tokens

With the introduction of Design Tokens, Paragon now also has its own CLI that can be used for building design tokens into a theme that can be loaded in MFEs.

Unlike the branding package, you don't need to install a design tokens package into the MFE. Instead, you tell the MFEs where they can find the stylesheets and it will load them directly from there. In Sumac, this location needed to be specified in the environment variables at build time, however with Teak and above you can simply specify these via the MFE Config API via site configuration.

Here is the relevant configuration you need to set for the MFE Config API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "PARAGON_THEME_URLS": {
        "core": {
            "urls": {
                "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css",
                "brandOverride": "http://localhost:3000/core.css",
            }
        },
        "default": {
            "light": "light",
        },
        "variants": {
            "light": {
                "urls": {
                    "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css",
                    "brandOverride": "http://localhost:3000/light.css"
                }
            }
        }
    }
}

A few key things to note here:

  • The "default" URLs here are the base Paragon versions that will apply the vanilla Open edX appearance.
  • The "brandOverride" URLs should point to the CSS files built by the new Design Tokens system.
  • The $paragonVersion in the URLs will be filled in with the current Paragon version used by the MFE. This will ensure that you can have a single config that will cause each MFE to load the correct version, even after upgrades.
  • The reason for using localhost:3000 is that this is the default URL used by the Paragon CLI for testing.

You can put all the following in a Tutor plugin to configure this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from tutor import hooks

paragon_theme_urls = {
    "core": {
        "urls": {
            "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css",
            "brandOverride": "http://localhost:3000/core.css",
        }
    },
    "default": {
        "light": "light",
    },
    "variants": {
        "light": {
            "urls": {
                "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css",
                "brandOverride": "http://localhost:3000/light.css"
            }
        }
    }
}

pargon_theme_config = f"""
MFE_CONFIG["PARAGON_THEME_URLS"] = {json.dumps(paragon_theme_urls)}
"""

hooks.Filters.ENV_PATCHES.add_item(
    (
        "mfe-lms-common-settings",
        pargon_theme_config,
    )
)

Now that you know how to load these stylesheets into an MFE, let's look at how you can generate them in the first place.

The simplest way to generate these files is to do it by hand. You can simply create a file called core.css and one called light.css fill in whatever you want and serve it at localhost:3000 (if that's what's configured as above) and the MFEs will load your "theme". You can try modifying some of the CSS variable values and see how that impacts the UI. You can simply serve this using python -m http.server 3000.

As a simple test, create a folder with a light.css file with the following contents and serve it at port 3000 on localhost:

1
2
3
:root {
  --pgn-colour-btn-bg-primary: #00a500;
}

If you now load the learner dashboard, you'll see how the buttons have changed colour. You can modify the colour, refresh and see how the changes apply Immediately. Let's now see how you can do this with design tokens instead.

All you need for design tokens is a plain simple folder and a version of Paragon installed. You can use the Paragon CLI to build design tokens from any folder.

There are two steps to building design tokens from a collection of JSON or TOML files into a loadable theme.

The first step build-tokens will convert JSON and TOML tokens into CSS files that contain CSS variable values.

The second step build-scss will combine the above CSS files and Paragon into a core.css and theme stylesheets (light.css).

If you have the Paragon CLI installed via NPM (just install @openedx/paragon) or you have Paragon cloned somewhere (you can then run node <paragon_src>/bin/paragon-scripts.js) you can now build your tokens.

Design Tokens need to be structured in a particular way for them to work. Mainly you need to have a directory (e.g. my-tokens) for all your tokens. Your directory should have the following structure:

  • build: This is where your tokens will be built into CSS
  • dist: This is where your final stylesheets will be output
  • my-tokens: This is where all your tokens will be stored
  • core: This will contain JSON and TOML core tokens
  • themes: Each theme should have its own folder with a hierarchy of JSON and TOML files.
    • light: For the default light theme
    • ...: Any more themes you'd like

Now inside this folder you can run the following command:

1
paragon build-tokens --source ./my-tokens --build-dir ./build  --themes light

This will build your light theme tokens from my-tokens into the build directory. You can omit the build directory since build is already the default.

Next, you need to run the following:

1
paragon build-scss --themesPath ./build/themes --excludeCore

You can use excludeCore if you're not producing any core stylesheet. Otherwise you can point it to the core CSS file produced by the prior build step. If you want to extend the core stylesheet with additional CSS, you can point to a custom core.scss file here and

You can now use paragon serve-theme-css to serve the themes in the dist directory, or use any other server, or upload it to a CDN.

Currently the only theme in the platform is the light theme leaving room for a future dark theme.

Hopefully this document will leave you feeling more confident about the recent changes to the platform and how you can work with them!

Resources for Further Reading