DEV Community

Cover image for Build your own emotion like CSS-in-JS library.
Late Night Coder
Late Night Coder

Posted on

Build your own emotion like CSS-in-JS library.

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.

emotion.js css function

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.

css function generates unique classname

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 ] ) 
Enter fullscreen mode Exit fullscreen mode

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.

Injected styles

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 given style-object.
  • It compiles the style-object into valid CSS and injects a style tag containing our compiled CSS into the document.head

The css Function

Let's break it down is a series of programmatic steps:

  1. Convert to a valid style-object.
  2. Generate a unique className for the style-object.
  3. Parse the style-object to generate valid CSS styles and attach them to className.
  4. 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), {}); } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
  • For maintaining Pure and Idempotent nature of getClassName function we will hash style-object so that it always returns the same output className for the same structured style-object. The hashing function needs input to be a string so we need to convert our style-object into a string. I will simply use JSON.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 
Enter fullscreen mode Exit fullscreen mode
  • JSON.stringify is not an ideal stringifying utility as for same looking object it gives different string output. So If we plan to use JSON.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; } 
Enter fullscreen mode Exit fullscreen mode

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; } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 ); } 
Enter fullscreen mode Exit fullscreen mode

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 from parseStyles 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 } 
Enter fullscreen mode Exit fullscreen mode

🎉*** 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) }; // } 
Enter fullscreen mode Exit fullscreen mode
  • 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 in css 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 any style-object.
  • These names are the only scope of keyframes it is global.
  • If you note carefully we never write the @keyframes keyword in the keyframes function call so that is something added internally along with the animation-name.
  • This conversion from a keyframe-style-object to style-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 }); 
Enter fullscreen mode Exit fullscreen mode

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 takes style-object or an array of style-object.
  • It stringifies this style-object and generates a unique hashed representational string for it, eg:css-123.
  • For the keyframe we convert keyframe-style-object to valid a style-object representation of @keyframe keyword.
  • These styles are then parsed. Each property in style-object may be on the of the following At(@) rules, &:hoveri.e multiple nested selector rules or fontSize: '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 valid CSS string representation of our style-object.
  • This CSS string is added into a stylesheet in DOM and appended to document.head.

And now as for naming this library, I will like to call it - Styler

Top comments (0)