DEV Community

Dominic
Dominic

Posted on • Edited on

Making a tiny zero-cost CSS-in-JS solution for React components (and apps)

I've been extensively using Emotion on a large project at work and warmed to the idea of CSS-in-JS. However I've always been skeptical of a few of things:

  1. Is the performance really as "blazing fast" as they claim?
  2. Do I want to add 28KB min to my very lightweight component library?
  3. Is it really such a good idea to litter CSS with props and logic, or can the same thing be achieved with conditional styles, classes and CSS Variables in a much more performant and logic-less fashion?
  4. Is using dynamically generated class names really that great for a component library which people might want to style themselves?

Let's address performance. At first the cost of Emotion was unnoticeable but as the product grew we started to notice there was significant lag in our Storybook. The page displaying all the Button variations for example can freeze for up to 10 seconds now. I checked Chrome Devtools and 90% of that time is spent in Emotion parsing functions… I have yet to do a full analysis on the actual app but a cursory check revealed that on the development build at least, about 20% of scripting time is spent in Emotion functions, and I know for a fact that there is virtually no compile time optimization done and it's only React that gets faster in production builds (e.g. faster hooks that aren't called twice). What's more is that Emotion uses a context Consumer in every component, and wraps components which use styled in HOCs.

Especially when developing re-usable components it makes sense to make them as small and efficient as possible. At the same time asking your users to import CSS is a bit of a drag, and looses the convenience of being able to theme with JS Objects.

Well it turns out you can build a zero-cost CSS-in-JS solution with theming if you're ok with ditching logic in CSS and hashed class names (in favour of something like BEM). You can even keep the nice syntax highlighting using the same plugins as you would for Emotion or Styled Components.

Creating the solution involves 3 things:

  1. A useStyles hook for inserting css into the DOM.
  2. A useTheme hook used once in your project for setting and updating CSS Variables.
  3. A css template literal function that doesn't do anything except give you the same syntax highlighting as when using Emotion or Styled Components.

The useStyles hook

import { useLayoutEffect } from 'react';

const styles = new Map<string, HTMLStyleElement>();

export function useStyle(uid: string, rules: string) {
  useLayoutEffect(() => {
    if (styles.get(uid)) {
      return;
    }

    const style = document.createElement('style');
    style.innerHTML = rules;
    style.setAttribute('id', uid);
    document.head.appendChild(style);
    styles.set(uid, style);

    return () => {
      if (style && document.head.contains(style)) {
        document.head.removeChild(style);
        styles.delete(uid);
      }
    };
  }, [uid, rules]);
}

Enter fullscreen mode Exit fullscreen mode

We simply take css (as a string) and insert it into a style tag. In this case useStyle is decorative, we're not actually using hooks but it feels more natural to hook users.

The useTheme hook

We'll want to use CSS Variables for theming and we also want our users to be able to pass a theme JS Object for convenience.

import { useLayoutEffect } from 'react';

type AnyTheme = Record<string, string>;

function makeCssTheme<T = AnyTheme>(prefix: string, theme: T) {
  return Object.keys(theme).reduce((acc, key) => {
    const value = theme[key as keyof T];
    if (value) {
      return acc + `${`--${prefix}-${key}`}: ${value};\n`;
    }
    return acc;
  }, '');
}

export function useTheme<T = AnyTheme>(prefix: string, theme: T, selector = ':root') {
  useLayoutEffect(() => {
    const style = document.createElement('style');
    const cssTheme = makeCssTheme(prefix, theme);

    style.setAttribute('id', `${prefix}-theme`);
    style.setAttribute('data-selector', selector);
    style.innerHTML = `
        ${selector} {
          ${cssTheme}
        }
      `;

    document.head.appendChild(style);

    return () => {
      if (style && document.head.contains(style)) {
        document.head.removeChild(style);
      }
    };
  }, [prefix, theme, selector]);
}
Enter fullscreen mode Exit fullscreen mode

See the example below for how it's used.

css template literal

Finally we want a css template literal function purely for syntax highlighting. It just smooshes your template string (which could have variables) into one string.

export const css = (strings: TemplateStringsArray, ...args: unknown[]) =>
  strings.reduce(
    (acc, string, index) => acc + string + (index < args.length ? args[index] : ''),
    ''
  );
Enter fullscreen mode Exit fullscreen mode

See the example below for how it's used.

Putting it all together

You now have a super fast and lightweight solution for CSS-in-JS! Let's see how it all fits together:

import * as React from 'react';
import { useTheme, useStyle, css } from 'aneto';

const defaultTheme = {
  appFont: 'sans-serif',
  buttonBg: 'red',
  buttonPadding: '10px',
  buttonPaddingSmall: '5px',
};

export function App({ theme = defaultTheme }) {
  useTheme('xx', theme);
  useStyle('app', componentStyles);

  return (
    <div className="app">
      <Button size="small">Some button</Button>
    </div>
  );
}

const componentStyles = css`
  .app {
    height: 100%;
    font-family: var(--xx-appFont);
  }
`;
Enter fullscreen mode Exit fullscreen mode

And a component:

import * as React from 'react';
import { useStyle, css } from 'aneto';

export function Button({ size = 'normal', children, ...attrs }) {
  useStyle('button', componentStyles);

  return (
    <button className={`button button--${size}`} {...attrs}>
      {children}
    </button>
  );
}

const componentStyles = css`
  .button {
    background: var(--xx-buttonBg);
    padding: var(--xx-buttonPadding);
  }
  .button--small {
    padding: var(--xx-buttonPaddingSmall);
  }
`;
Enter fullscreen mode Exit fullscreen mode

Runnable example: https://codesandbox.io/s/simple-zero-cost-css-in-js-example-cifhi

NPM package: https://www.npmjs.com/package/aneto
GitHub: https://github.com/DominicTobias/aneto

Full featured alternatives for compile time CSS-in-JS:
https://github.com/callstack/linaria
https://github.com/atlassian-labs/compiled

Top comments (0)