Architect a Theming System — Component Themes with Sass — Part 3

Simeon Simeonoff
9 min readSep 18, 2019

--

Photo by RhondaK Native Florida Folk Artist on Unsplash
Photo by RhondaK Native Florida Folk Artist on Unsplash

In my previous two articles I talked about coming up with strategies for building reusable component themes by structurally splitting our components into several parts, one of which was colors and palettes.

If you haven’t read them yet, here are the links:

In this article I will expand on those ideas by providing a more practical approach to solving the problem of component themes in Sass. We will be using the palette and color functions we’ve created in Part 2, as well as some additional utility functions and mixins.

Here are the requirements that our component themes should fulfil:

  • Component themes modify the visual properties of a component, such as colors, roundness, and elevation, etc;
  • Component themes should be based on a blueprint, which we will call schema. Each component theme should have its own schema;
  • A component schema should not carry any hard-coded values, such as colors or sizes. It’s only a prescription for an eventual component theme;
  • Component themes should accept palette as an input;
  • Component themes should be extensible;
  • Component themes should be reusable;
  • The output of a component theme is only compiled into CSS if the theme is included using the @include directive;
  • Component themes should support custom CSS properties (variables) so they can be modified after compilation, i.e. outside of Sass; **

In case you are curious about the end result before reading the article, you can explore the full implementation in this CodeSandbox Sample.

API Overview

Instead of jumping straight down to the implementation details of each of the requirements, let’s take a look at how we want the finalized API to look and feel like for the end user.

// 1. Create a color palette
$my-palette: palette(royalblue, seagreen);
// 2. Include the predefined component styles by passing the
// palette and schema for the given component
.component {
@include component($palette, $schema);
}

In step 1 of our sample API we call a function unimaginatively named palette that returns a map of colors that will be used by the mixin component we call in step 2.

The mixin component should resolve the component $schema by going through each of its properties, applying the values of $palette.

Component Schemas

Component schemas are simple recipes that inform the component theme about its properties — what color variants from the palette a specific part of the component should use, what its roundness factor(s) should be, i.e. the value of the border radius between a minimum and a maximum value, and what elevation (shadow) the component should throw in its different states (resting, hover, active, etc.). We will cover roundness and elevation in another article for the sake of brevity.

To illustrate the concepts in this article, let’s assume we want to create a functional component theme a with proper schema for a button component.

Here’s the design of our button component:

We can immediately notice that the button has 4 states, two of which have the same style, but we shouldn’t assume that the user of our theming system shouldn’t be allowed to choose a different background/text color when the component is in its focus state.

Here’s some additional information about the component design, that isn’t immediately obvious from the picture above:

— the default border-radius on all sides is 4px

— the default background-color in each of the 4 states:

  • Resting State — Primary 500
  • Hover State — Primary 700
  • Focus State — Primary 700
  • Disabled State — Gray 100

— the default text color in each of the 4 states:

  • Resting State — #fff
  • Hover State — #fff
  • Focus State — #fff
  • Disabled State — Gray 300

A note about the text colors — WCAG2 level AAA calls for a 7:1 contrast ratio of normal text against the background it sits on top of for better accessibility. It could be useful to use/write a function that returns the contrast color for a given color according to the WCAG2 standards, which is exactly what we do in our actual implementation.

It becomes apparent from the list of properties above that we need at least 9 properties — 4 for the background colors, 4 for the corresponding text colors and 1 for the border-radius; This is where schemas come into play. The button schema is simply a map of key value pairs that describe the aforementioned properties. Here’s how the schema for the button will look like:

$button-schema: (
border-radius: 4px,
resting-background: (
color: ('primary', 500)
),
resting-text-color: (
contrast-color: ('primary', 500)
),
hover-background: (
color: ('primary', 700)
),
hover-text-color: (
contrast-color: ('primary', 700)
),

focus-background: (
color: ('primary', 700)
),
focus-text-color: (
contrast-color: ('primary', 700)
),
disabled-background: (
color: ('gray', 100)
),
disabled-text-color: (
color: ('gray', 500),
transparentize: .12
)
);

This way of describing the default properties of a theme can look a little weird at first. The idea is to escape from having hard values assigned. The schema above doesn’t know anything about the values behind the primary color palette.

A side note — Unfortunately, Sass isn’t a typed language, and this makes things a bit more unstable. If we ensure, however, that all color variants are part of the palette we resolve the values from, then this approach should be a-okay.

Now, the other thing above is how colors are described in their own map, for instance:

disabled-text-color: (
color: ('gray', 500),
transparentize: .12
)

When properties are described using a map, such as the one above, then we consider that to be a set of instructions, where the key color is a function name and the value (gray, 500) is the arguments the function will receive. The output of the color function call is then fed to the next function in the map, i.e. transparentize as its first argument and the value .12 as a second argument. In the next section we take a look at how we resolve those maps. The important takeaway here is that we don’t really know anything about the palette yet. This is why we expect the user to pass both a palette and a component schema. We take both and mix them to produce the final values.

Resolving Schema Values

Let’s create a function that is capable of resolving the values in a schema. The function will accept an instruction map and call each function with its arguments, then pass the result to the next function and so on until completion:

@function resolve-value($map, $extra: null) {
$result: null;

// Iterate over the <key>:<value> pairs in the instruction map
@each $fn, $args in $map {

// Check if the function exists before trying to execute it
@if function-exists($fn) {

// get a handle to the function
$fn: get-function($fn);

@if $result == null {
@if($extra) {
$result: call($fn, $extra, $args...);
} @else {
$result: call($fn, $args...);
}
} @else {
$result: call($fn, $result, $args...)
}
}
}

@return $result;
}

We first iterate over the key-value pairs inside the instructions map and extract each key as a function name and the value as the arguments to be passed to the function. We then check whether the function exists before proceeding to calling it. Then we check whether there’s anything stored in the $results reference. If there’s nothing we check if there’s any $extra arguments we want to pass to the function and call it. The produced value is then stored in the $results reference. After the first function has been executed, every consecutive function (if any) in the map will be called with the value of $result as its first argument. Any supplied arguments will then be passed to the function call. If there are no more functions, we return the final $result.

If we run an instruction set through the resolve-value function, we will get a color from the palette we passed as an extra argument; Here’s an example:

// Create a palette
$palette: palette(royalblue, seagreen);
// Create an instruction map
$value: (
contrast-color: ('secondary', 400),
transparentize: .12
);
// Resolves to rgba(58, 148, 98, 0.88);
$resolved-value: resolve-value($value, $palette);

Now that we have a function that can resolve values given an instruction set and a palette, we need a function that can go through an entire schema and resolve all values from any instruction set. Let’s create that function next:

@function resolve-schema($schema, $palette) {
$result: ();

@each $key, $value in $schema {
@if type-of($value) == 'map' {
$result: map-merge(
$result,
(
#{$key}: resolve-value($value, $palette)
)
);
} @else {
$result: map-merge(
$result, (
#{$key}: #{$value}
)
);
}
}
@return $result;
}

The function resolve-schema takes two input arguments — a $schema and a $palette; We iterate over the schema to get each key-value pair in the map; If the value is of type map, we assume it’s an instruction set that needs to be resolved to a value, so we call the resolve-value function on it with the value, and since we are going to be resolving colors, we pass the $palette as an extra argument. We then merge the resolved value in a $result map with the $key from the schema; In case the $value is not a map, we merge it as it is in our $result map; Finally, we return the $result map to the user with all schema values now resolved.

Theme Mixins

Now that we have a strategy fоr describing themes in terms of colors, we can proceed to coming up with a pattern for how those themes can produce CSS. We will use a mixin to build our button theme.

The ides is that our button theme will accept a schema and a palette, resolve the schema with the given palette, then use the resolved values inside the CSS rules. Let’s get started:

@mixin button($palette, $schema) {
$theme: resolve-schema($schema, $palette);
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
outline-style: none;
background-color: map-get($theme, 'resting-background');
color: map-get($theme, 'resting-text-color');
border-radius: map-get($theme, 'border-radius');
padding: 12px 16px;
font-size: 14px;
cursor: pointer;
transition: background-color .25s ease-out;

&:hover {
background-color: map-get($theme, 'hover-background');
color: map-get($theme, 'hover-text-color');
}

&:focus {
background-color: map-get($theme, 'focus-background');
color: map-get($theme, 'focus-text-color');
}
&[disabled] {
background-color: map-get($theme, 'disabled-background');
color: map-get($theme, 'disabled-text-color');
pointer-events: none;
}
@content;
};

As you can see from the sample above, all of our colors are consumed from the resolved theme. We can now customize the button by just altering the palette/schema passed to the button mixin. We can have unlimited palettes and schemas that we can pass around to button mixins.

$royal-palette: palette(royalblue, seagreen);
$firebrick-palette: palette(firebrick, orange);
.royal-button {
@include button($royal-palette, $button-schema);
}
.firebrick-button {
@include button($firebrick-palette, $button-schema);
}

Now we can apply the produced button styles to some markup:

<button class="royal-button">Royal</button>
<button class="firebrick-button">Firebrick</button>

We can even change the recipe for each button by extending the original $button-schema:

// Create a new button schema from the original
$custom-button-schema: extend(
$button-schema,
(
resting-text-color: (secondary, 500)
)
);
// Pass the new schema to our firebrick button
.firebrick-button {
@include button($firebrick-palette, $custom-button-schema);
}

This approach allows us to separate the structural styles of a component, in this case the button, within its own mixin, leaving all other aspects of it stored in a schema and a palette, exposing just the parts we want. This allows us to modify the underlying styles, without affecting the end-user experience in case future versions of the theme are introduced. We can add more properties to the schemas and still preserve backward compatibility.

In the next part, we will incorporate custom CSS properties without changing the much about the current approach. This will allow the users of our themes to be able to alter the colors of the theme by changing the value of the corresponding CSS variable.

** Some of the functions used to achieve the end result were not explained in this article. If you want to explore a working implementation of the approach described here, please take a look at the full CodeSandbox Sample.

--

--