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. ```js 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 { }``` 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`. ```js // 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
Top comments (0)