In this post we will discard our changes in the
previous post
and switch to a new approach.
End result
CSS variables
CSS variables
are also known as CSS Custom Properties. At a very high level:
- We can set some value on a variable using
--variable-name: value
. The
variable name has to start with --
, and the definition has to happen
within some selector or as inline styles.
- We can then read the value of a variable and supply it to some CSS
property
with the
var()
function, e.g. color: var(--theme-color);
- CSS Variables will cascade into inner elements.
Approach
We will update our CSS rule generation code to support dynamic properties
with CSS variables, we then set the value of each variable using inline
styles. This way, we will generate only a single class name per dynamic
style.
Take this styled component we built in the last post as an example:
1
2
3
4
5
|
const Block = styled((props: Props) => ({
border: "1px solid black",
background: props.mode === "light" ? "white" : "black",
fontSize: props.size === "big" ? "2em" : "1em",
}));
|
We should generate a CSS rule that looks like this
1
2
3
4
5
|
.c1 {
border: 1px solid black;
background: var(--c1-background);
font-size: var(--c1-font-size);
}
|
then we need to apply the following inline style:
1
2
3
4
5
6
|
<div
style={{
"--c1-background": props.mode === "light" ? "white" : "black",
"--c1-font-size": props.size === "big" ? "2em" : "1em",
}}
></div>
|
Update to CssLikeObject
With this approach, we need to know exactly what properties are dynamic. Our
previous API for dynamic styles doesn’t work any more. Let’s update our
CssLikeObject
definition:
1
2
3
4
5
6
7
8
9
10
11
|
type DynamicCssProperties<Props = {}> = {
[k in keyof CSS.PropertiesHyphen]:
| CSS.PropertiesHyphen[k]
| ((props: Props) => CSS.PropertiesHyphen[k]);
};
type CssLikeObject<Props = {}> =
| {
[selector: string]: DynamicCssProperties<Props>;
}
| DynamicCssProperties<Props>;
|
With this new API, our styled component now looks like this:
1
2
3
4
5
|
const Block = styled({
border: "1px solid black",
background: (props: Props) => (props.mode === "light" ? "white" : "black"),
fontSize: (props: Props) => (props.size === "big" ? "2em" : "1em"),
});
|
Rebuilding nestedDeclarationToRuleStrings
First, let’s define the new behavior. nestedDeclarationToRuleStrings
should take in a rootClassName
and a new CssLikeObject
as before.
However, it’s not sufficient to just output some CSS rules - we also
need to know what CSS variables are created and how those CSS variables
should be filled. We can do that with this new function interface:
1
2
3
4
5
6
7
|
function nestedDeclarationToRuleStrings<Props = {}>(
rootClassName: string,
declaration: CssLikeObject<Props>
): {
rules: string[];
variablesValueMapping: (props: Props) => CSS.PropertiesHyphen;
};
|
In addition to the original rules string, we also provide a way to compute
the values of all variables that the rules string may contain. We will
place the rules
strings into our dynamic stylesheets and the
rootClassName
to the class name of our elements as before, but now we
also need to compute the values of variables and place them into the inline
styles of our elements.
Before doing that, let’s update the implementation of
nestedDeclarationToRuleStrings
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
function nestedDeclarationToRuleStrings<Props = {}>(
rootClassName: string,
declaration: CssLikeObject<Props>
): {
rules: string[];
variablesValueMapping: (props: Props) => CSS.PropertiesHyphen;
} {
const resultRules: string[] = [];
const resultMapping: {
[s: string]: (props: Props) => string;
} = {};
// Make a new CSS variable and record the mapping
let count = 0;
function _makeNewVariable(valueDef: (props: Props) => string) {
const variableName = `--${rootClassName}-${count}`;
count++;
resultMapping[variableName] = valueDef;
return variableName;
}
function _helper(selector: string, declaration: CssLikeObject<Props>) {
// We split the props into either nested dynamic css rules
// or plain dynamic css props.
const nestedNames: string[] = [];
const cssProps: DynamicCssProperties<Props> = {};
for (let prop in declaration) {
const value = (<any>declaration)[prop];
if (value instanceof Object && typeof value !== "function") {
nestedNames.push(prop);
} else {
(<any>cssProps)[prop] = value;
}
}
const lines: string[] = [];
// Collect all generated css rules.
lines.push(`${selector} {`);
for (let prop in cssProps) {
// collect the top level css rules
if (typeof (<any>cssProps)[prop] === "function") {
// Since this is a function, we know that it should be represented
// by css variables
lines.push(`${prop}:var(${_makeNewVariable((<any>cssProps)[prop])});`);
} else {
lines.push(`${prop}:${(<any>cssProps)[prop]};`);
}
}
lines.push("}");
// Each string has to be a complete rule, not just a single
// property
resultRules.push(lines.join("\n"));
// Go through all nested css rules, generate string css rules
// and collect them
nestedNames.forEach((nestedSelector) =>
_helper(
joinSelectors(selector, nestedSelector),
(<any>declaration)[nestedSelector]
)
);
}
_helper("." + rootClassName, declaration);
return {
rules: resultRules,
variablesValueMapping: (props) => {
const result: { [s: string]: string } = {};
Object.keys(resultMapping).forEach((k) => {
result[k] = resultMapping[k](props);
});
return result;
},
};
}
|
Now we can update our StyleDef
class. Notice that there’s no longer a need
to differentiate static and dynamic styles, so we will remove the
staticStyle
function and replace it with a more generic style
function.
All other utilities remain the same.
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
33
34
35
36
|
class StyleDef {
#selectorCounter = 0;
#sheet: CSSStyleSheet;
#prefix: string;
constructor(prefix: string) {
const sheetElement = document.createElement("style");
document.head.appendChild(sheetElement);
this.#sheet = sheetElement.sheet!;
this.#prefix = prefix;
}
style<Props = {}>(
declaration: CssLikeObject<Props>
): {
className: string;
variablesValueMapping: (props: Props) => CSS.PropertiesHyphen;
} {
const selector = this.#generateSelector();
const { rules, variablesValueMapping } = nestedDeclarationToRuleStrings(
selector,
declaration
);
rules.forEach((ruleString) =>
this.#sheet.insertRule(ruleString, this.#sheet.cssRules.length)
);
return {
className: selector,
variablesValueMapping,
};
}
#generateSelector = () => {
return `${this.#prefix}-${this.#selectorCounter++}`;
};
}
|
Next let’s create a similar styled
function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
const styleDef = new StyleDef("somePrefix");
function styled<T extends {}>(
declaration: CssLikeObject<T;
) {
const {
className,
variablesValueMapping
} = styleDef.style(declaration);
const result: any = React.forwardRef((props: T, ref) => {
return React.createElement("div", {
...props,
className: [className, (<any>props).className ?? ""].join(" "),
style: {
...variablesValueMapping(props),
...(<any>props).style
},
ref
});
});
return result;
}
|
This is very similar to what we have before, but it works just fine if we
pass in a static style. In addition, if we take a look at the few issues we
mentioned in the last post, there are a few benefits.
Since we generate only a single class per style:
- There’s no worry with an unbounded number of class names (duh!)
- Descendant selector will work
- Static extraction (generating CSS code during build time) is doable
Issues
Does not support IE
IE doesn’t support CSS variables
. Although
if you still need to support IE in 2021, supporting CSS-in-JS will be the least
of the troubles.
Large generated stylesheet
The approach detailed in this post doesn’t try to avoid generating
duplicated styles across different style definitions. However, we can still
apply the same atomic CSS strategy to this approach, so this is not really
an inherent issue with the approach of using CSS variables.
Restricted syntax
This may not be a big deal, but we had to switch our API to be able to
detect dynamic rules. This means that we can’t do something like this:
1
2
3
4
5
6
7
8
9
|
const Block = styled((props: Props) =>
props.isByWidth
? {
width: "100px",
}
: {
height: "100px",
}
);
|
Again we can achieve the same result with either API, but to some, the above
may be a bit cleaner.
As with anything fancy, CSS variables probably come with some overhead.
This post
mentions the fact that CSS variable can slow down startup time, while
another post
talks about some gotchas with scoping. Some more proper investigation
is likely warranted though, as it’s possible that the issue in the first
post was due to the scoping issue mentioned in the second.
More importantly, because it’s a lot easier to perform static extraction
with this approach, we can likely reduce the performance footprint compared
to our previous approach. We will talk about static extraction in our next
post.
Posts in this Series