Build your own emotion like CSS-in-JS library

Build your own emotion like CSS-in-JS library

ยท

12 min read

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 Late Night Coder by becoming a sponsor. Any amount is appreciated!

ย