In this blog post, we will try to understand CSS-in-JS by creating our own version of emotion.js. Emotion is one of the most popular CSS-in-JS solutions out there.
There are some brilliant minds working on emotion, we aren't going to recreate emotion with all its complexities and optimization just that we are trying to develop a better understanding of such CSS-in-JS libraries by building one on our own.
Let us start by exploring API and see what emotion does for us.
So the emotion package exports a function called css
which takes style-object
as an argument and returns a unique className
which is used by ourdiv
to apply some style.
Another overload signature css
function can also take an array of style-object
as an argument. For example:
css(
[ // <-- array of
{ padding: '32px', backgrounColor: 'orange' }, // <-- style object 1
{ fontSize: '24px', borderRadius: '4px' } // <-- style object 2
]
)
In the DOM css
function injects a <style>
in the document.head
where it keeps the compiled CSS created from calling css
function on the style-object
.
Summarising emotion css
function:
css
function takes an object or array of objects as an argument.- These objects are called
style-object
which is a way of writing CSS with JavaScript objects. - It returns a unique
className
for the givenstyle-object
. - It compiles the
style-object
into validCSS
and injects astyle
tag containing our compiledCSS
into thedocument.head
The css
Function
Let's break it down is a series of programmatic steps:
- Convert to a valid
style-object
. - Generate a unique className for the
style-object
. - Parse the
style-object
to generate valid CSS styles and attach them to className. - Inject the parsed CSS into a stylesheet in DOM.
Following our programmatic steps our css
function should look like:
function css(styles) {
// 1. convert to valid style-object
const _style_object_ = getValidStyleObject(styles);
// 2. generate unique className
const className = getClassName(_style_object_);
// 3. Parse the style-object to generate valid CSS styles and attach them to className
const CSS = parseStyles(_styles_object_, className);
// 4. Create or update the stylesheet in DOM
injectStyles(CSS);
// return className to be applied on element
return className;
}
Step 1: Convert to a valid style-object
The css
function can accept a style-object
or an array of style-object
. In the case of the array of style-object
we must merge those to generate a single style object.
function getValidStyleObject(styles) {
let style_object = styles;
if (Array.isArray(styles)) {
style_object = merge(styles);
}
return style_object;
}
function merge(styles) {
// (*) shallow merge
return styles.reduce((acc, style) => Object.assign(acc, style), {});
}
It should be noted that this is a shallow merge which will simply replace the properties of the former one with a later one, for the nested properties simple replacement may cause an issue so we out for deep-merge if required
Step 2: Generate a unique className for the style-object
After step 1 we have received a valid style-object
and now we can process this object to generate unique className for it.
- Generating a unique className is required makes sure that there are no naming conflicts anywhere in the application;
- Unique className eliminates the need for any naming conventions like BEM, which makes the life of the dev easier.
- For generating names we should make sure that we always come up with the same name for the same structured style object.
const styleObject1 = {
fontSize: '16px',
fontWeight: 600
};
const styleObject2 = {
fontSize: '16px',
fontWeight: 600
};
styleObject1 === styleObject2; // false: reference is different
getClassName(styleObject) === getClassName(styleObject2); // true: Pure and Idempotent nature
- For maintaining Pure and Idempotent nature of
getClassName
function we will hashstyle-object
so that it always returns the same output className for the same structuredstyle-object
. The hashing function needs input to be a string so we need to convert ourstyle-object
into a string. I will simply useJSON.stringify
for our case. But there is a catch see below.
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };
obj1 == obj2; // false: diff references
// an ideal stringifying function
stringify(obj1) === stringify(obj2) // true: '{ "a": 1, "b": 2 }'
// our JSON.stringify
JSON.strinfigy(obj1) === JSON.stringify(obj2) // false
// '{ "a": 1, "b": 2 }' === '{ "b": 2, "a": 1 }' // false
JSON.stringify
is not an ideal stringifying utility as for same looking object it gives different string output. So If we plan to useJSON.stringify
our hashes will also vary.
// it is a cache map of "serialized-style-object" to "hashed-style-object"
const style_classname_cache = {};
function getClassName(styleObject) {
const stringified = stringify(styleObject);
// pick the cached className to optimize and skip hashing every time
let className = style_classname_cache[stringified];
// if there is not an entry for this stringified style means it is new
// so generate a hashed className and register and entry of style
if (!className) {
// use any quick hashing algorithm
// example: https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781
const hashed = hash(stringified);
// prefix some string to indicate it is generated from lib
// it also makes sure that className is valid
const _class_name_ = `css-${hashed}`;
// hashing is costly so make an entry for the generated className
style_classname_cache[stringified] = _class_name_;
className = style_classname_cache[stringified];
}
return className;
}
Now let's proceed to next step where we will parse the style-object
to generate CSS string.
// it is a map of "stringified-style-object" to "hashed-classname"
const style_classname_cache = {};
// inside css function
// ...
const className = getClassName(....);
let CSS = classname_css_cache[className];
if (!CSS) {
CSS = parseStyles(_style_object_, className); // <-- Step 3
classname_css_cache[classname] = CSS;
}
Step 3: Parse the style-object
to generate valid CSS styles and attach them to className
This is the toughest part where we process the style-object
and generate valid CSS rule declations from them. Before we proceed let's take an example:
// style-object
const styles = {
width: '600px',
fontSize: '16px', // style-rule 1
fontWeight: 600, // style-rule 2,
color: 'red',
'&:hover, &:active': {
color: 'green',
},
'&[data-type="checkbox"]': {
border: '1px solid black'
},
'@media(max-width: 1200px)': {
width: '200px'
}
};
css(styles)
// compiled CSS from `style-object`
.css-123 {
font-size: 16px;
font-weight: 600px;
}
// ! NOTE !
// 1) .css-123 is selector name or class-name here
// 2) { and } marks style blocks/bound for this selector, each
// block need to be parsed
// 3) font-size: 16px is processed CSS for`fontSize: '16px'`
// 4) `&:hover, &:active' are two blocks ideally joined by a `,`
// i.e '&:hover' and '&:active'
// 5) '&:hover' block is read as `css-123:hover` where
// `&` is replaced by current selector name
// 6) `&[data-type="checkbox"]` attributes based styling is also possible
// 7) @ rules are specific rule ex: @media screen size rules
// so it should be processed early
// 8) each nested style (ex: &:hover) need to be parsed
// i.e recursive calling
From the above-gathered notes, we can write our parseStyles
as
function parseStyles(style_object, selector) {
// This collects `@import` rules which are independent of any selector
let outer = "";
// This is for block rules collected
let blocks = "";
2;
// This is for the currently processed style-rule
let current = "";
// each property of style_object can be a rule (3)
// or a nested styling 7, 8
for (const key in style_object) {
const value = style_object[key];
// @ rules are specific and may be further nested
// @media rules are essentially redefining styles on-screen breakpoints
// so they need to be processed first
const isAtRule = key[0] === "@";
if (isAtRule) {
// There are 4 main at-rules
// 1. @import
// 2. @font-face
// 3. @keyframe
// 4. @media
const isImportRule = key[1] === "i";
const isFontFaceRule = key[1] === "f";
const isKeyframeRule = key[1] === "k";
if (isImportRule) {
// import is an outer rule declaration
outer += key + " " + value; // @import nav.css
} else if (isFontFaceRule) {
// font face rules are global block rules but don't need a bound selector
blocks += parseStyles(value, key);
} else if (isKeyframeRule) {
// keyframe rule are processed differently by our `css` function
// which we should see implementation at a later point
blocks += key + "{" + parseStyles(value, "") + "}";
} else {
// @media rules are essentially redefining CSS on breakpoints
// they are nested rules and are bound to selector
blocks += key + "{" + parseStyles(value, selector) + "}";
}
}
// beside the At-Rules there are other nested rules
// 4, 5, 6
else if (typeof value === "object") {
// the nested rule can be simple as "&:hover"
// or a group of selectors like "&:hover, &:active" or
// "&:hover .wrapper"
// "&:hover [data-toggled]"
// many such complex selector we will have to break them into simple selectors
// "&:active, &:hover" should be simplified to "&:hover" and "&:active"
// finally removing self-references (&) with class-name(root-binding `selector`)
const selectors = selector
? // replace multiple selectors
selector.replace(/([^,])+/g, (_seletr) => {
// check the key for '&:hover' like
return key.replace(/(^:.*)|([^,])+/g, (v) => {
// replace self-references '&' with '_seletr'
if (/&/.test(v)) return v.replace(/&/g, _seletr);
return _seletr ? _seletr + " " + v : v;
});
})
: key;
// each of these nested selectors create their own blocks
// &:hover {} has its own block
blocks += parseStyles(value, selectors);
}
// now that we have dealt with object `value`
// it means we are a simple style-rules (3)
// style-rule values should not be undefined or null
else if (value !== undefined) {
// in JavaScript object keys are camelCased by default
// i.e "textAlign" but it is not a valid CSS property
// so we should convert it to valid CSS-property i.e "text-align"
// Note: the key can be a CSS variable that starts from "--"
// which need to remain as it is as they will be referred by value in code somewhere.
const isVariable = key.startsWith("--")
// prop value as per CSS "text-align" not "textAlign"
const cssProp = isVariable
? key
: key.replace(/[A-Z]/g, "-$&").toLowerCase();
// css prop is written as "<prop>:<value>;"
current += cssProp + ":" + value + ";";
}
}
return (
// outer are independent rules
// and it is most likely to be the @import rule so it goes first
outer +
// if there are any current rules (style-rule)(3)
// attach them to selector-block if any else attach them there
(selector && current ? selector + "{" + current + "}" : current) +
// all block-level CSS goes next
blocks
);
}
At this point, we have compiled CSS from style_object
and all that is left is to inject it into the DOM.
Step 4: Inject the parsed CSS into a stylesheet in DOM
For this step, we will create a <style>
tag using document.createElement
and inside of that style tag, we will append our styles in thetextNode
.
- Create a
<style id="css-in-js">
element if doesn't already exist; - Get the text-node i.e
stylesheet.firstChild
and append CSS string fromparseStyles
in it.
// in case the process isn't running in a browser instance
// so we fake stylesheet-text-node behavior
const fake_sheet = {
data: ''
};
// keep track of all styles inserted so that we don't insert the same styles again
const inserted_styles_cache = {};
function injectStyles(css_string) {
// create and get the style-tag; return the text node directly
const stylesheet = getStyleSheet();
// if already inserted style in the sheet we might ignore this call
const hasInsertedInSheet = inserted_styles_cache[css_string];
// these styles need to be inserted
if (!hasInsertedInSheet) {
stylesheet.data += css_string; // <-- inserted style in sheet
inserted_styles_cache[css_string] = true; // <-- mark the insertion
}
}
function getStyleSheet() {
// we aren't in the browser env so our fake_sheet will work
if (typeof window === "undefined") {
return fake_sheet;
}
const style = document.head.querySelector('#css-in-js');
if (style) {
return style.firstChild; // <-- text-node containing styles
}
// style doesn't already exist create a style-element
const styleTag = document.createElement('style');
styleTag.setAttribute('id', 'css-in-js');
styleTag.innerHTML = ' ';
document.head.appendChild(styleTag);
return styleTag.firstChild; // <-- text-node containing styles
}
๐ Congratulations with that in place we have created our own CSS-in-JS library. ๐
As for the keyframes
, we can use our css
function but with little modifications.
Let's see the API and how its use first.
const growAnimationName = keyframes({ // <-- argument is called keyframe-style-object
from: { transform: 'scale(1)' },
to: { transform: 'scale(2)' },
}); // <-- call to keyframe with style-object returns animation-name. eg: (css-987)
// used as
css({ width: '100px', height: '100px', animation: `${growAnimationName} 2s ease infinite` });
// compiled as
// @keyframe css-987 {
// from: { transform: scale(1) };
// to: { transform: scale(2) };
// }
- Keyframes have a similar API where it takes a
keyframe-style-object
. - Keyframes return to the animation name; they are not bound to a class/selector scope.
- The
css
function only needs an animation name to apply styling which means keyframes need not be incss
function style object definition. - Keyframes are global where
keyframe-style-object
is stringified and hashed to generate animation name same as in the case generating className from anystyle-object
. - These names are the only scope of keyframes it is global.
- If you note carefully we never write the
@keyframes
keyword in thekeyframes
function call so that is something added internally along with the animation-name. - This conversion from a
keyframe-style-object
tostyle-object
can look something like:
// keyframe-style-object
{
from: { transform: 'scale(1)' },
to: { transform: 'scale(2)' }
}
// converted style-object
{
[`@keyframes ${animationName}`]: keyframe-style-object
}
Adding keyframes support to css
function can be done simply by telling css
function to treat this css
call as a keyframe
function call and do the above conversion before parsing the style-object
.
// adding one more parameter called `options`
// this can be used to change the behavior of `css` function and
// it should be an optional parameter.
// changing the name to _css_ to indicate this is not exported and passing
// different values of options can yield different variations of _css_ functions
// to suit different requirements example keyframes
function _css_(styles, options) {
// ...same no change...
// in the parsing of the style function call
parseStyles(
// style-object
options.hasKeyframes
?
// convertion to valid style-object from a keyframe-style-object
{ [`@keyframe ${className}`]: _style_object_ }
:
_style_object_,
// selector
className
)
// ...same no change...
}
// final exported function from library
export const css = (style_object) => _css_(style_object, {});
export const keyframes = (style_object) => _css_(style_object, { hasKeyframes: true });
With keyframes
in place, we have successfully coded our CSS-in-JS library. So as promised we have created our emotion like library; Note that emotion is way more complex and handles many different edge cases with far better optimizations.
Summary of css
function
css
function takesstyle-object
or an array ofstyle-object
.- It stringifies this
style-object
and generates a unique hashed representational string for it, eg:css-123
. - For the
keyframe
we convertkeyframe-style-object
to valid astyle-object
representation of@keyframe
keyword. - These styles are then parsed. Each property in
style-object
may be on the of the followingAt(@) rules
,&:hover
i.e multiple nested selector rules orfontSize: '16px'
simple CSS properties. Each is dealt with differently as some can be block-scoped while others are global. Self-references using&
are also handled here. After the correct parsing, we generate a validCSS
string representation of ourstyle-object
. - This
CSS
string is added into a stylesheet in DOM and appended todocument.head
.
And now as for naming this library, I will like to call it - Styler