Aniket Jha
Late Night Coder

Late Night Coder

Build your own emotion like CSS-in-JS library

Build your own emotion like CSS-in-JS library

Aniket Jha's photo
Aniket Jha
·Aug 14, 2022·

12 min read

Subscribe to my newsletter and never miss my upcoming articles

Play this article

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. Screenshot 2022-08-14 at 12.05.10 PM.png

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.

Screenshot 2022-08-14 at 12.11.31 PM.png

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.

Screenshot 2022-08-14 at 12.11.18 PM.png

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.

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 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
  • 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;
}

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 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
}

🎉 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 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
{
  [`@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 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

Did you find this article valuable?

Support Aniket Jha by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this