DEV Community

aabdullin
aabdullin

Posted on • Edited on

CSS solutions Battle: Compile time CSS-in-JS vs CSS-in-JS vs CSS Modules vs SASS

In modern frontend development, especially in react, to a lesser extent in vue, these are many different ways to write CSS for our components.

In this article i will not make side-by-side comparison, i will highlight both interesting features and problems of specific solutions that I am guided by in a situation where i am in the position of a person who determines the "CSS architecture" of a project.

SASS (CSS, CSS-preprocessors)

SASS (further just CSS) is easy to learn but very hard to maintain. What is it mean?

Main problem of pure CSS, he don't solve problem of styles isolation per component. And all your styles will leak into other components, and this will create a lot of problems in large projects.
Yeah, this problem is as old as the world, and we have different ways to solve this problem:

But all of this solution it is only a metodologies, this does not remove the need for the developer to think, and this means that we still have such a problem as a banal human inattention.

And second problem, because all of our styles is just abstract global CSS we don't have a TypeScript support to check that our style really exist. And the resulting problem we don't have good IDE intelligence (especially if we have have additionalData that import of some files that contain SASS vars and mixins, in Webpack/Vite config).
Yeah, we have solutions like:

But these are just plugins for the IDE and will not be possible to integrate them into your CI/CD pipeline to check if we are using non-existing CSS classes.

CSS Modules

And in this point to solve all problems of having global CSS enters the stage CSS Modules.

Basically CSS Modules = CSS in JS objects.
CSS Modules it is the same as the CSS code structure and all. The main difference is calling methods is too different.

CSS Modules provide some JS Modules representation what contain links to CSS classes. And our classNames will be looks like <div className={style.css_class} />, and our class selector under the hood will be transform to something like [name]__[local]__[hash:base64:5] (more details here), that will solve CSS classes isolation problem.

But, what about TypeScript support?
And here we have some solutions:

  • TypeScript plugin CSS Modules, it is plugin that provide to TypeScript language service information about class selectors that contain imported CSS Module file. But for VSCode we need setup TypeScript LS to use workspace version. For more information go here or here.

It is solve problem using non-existed class names for TS/TSX files, but that if we use Vue and .vue files?
Here we have problem, because volar for example does not provide support of TypeScript plugin CSS Modules, for more information go here.
And there enters the stage:

And we have type checking for Vue projectπŸ₯³

And what about IDE autocompletion for SCSS/SASS vars, mixins?
Everything is the same here, only SCSS IntelliSense

But CSS not feature rich language. How we can add even more flexibility and improve Development Experience of writing styles?

CSS-in-JS

Also since we write js, we able to write helper functions for our css fragments, what will be fully support TypeScript, which means significantly reduces the number of errors and get IDE intellicence.

For basic example it is a media querys, and js variables for theming.

export const screenSizes = {
  mobile: 767,
  tablet: 1023,
  computer: 1440,
  desktop: 1920,
} as const

export const makeMedia = (from: null | number, to?: null | number) => `@media screen${
  from
    ? ` and (min-width: ${from}px)`
    : ''}${
  to
    ? ` and (max-width: ${to - 1}px)`
    : ''}`

export const media = {
  mobile: makeMedia(null, screenSizes.mobile),
  tablet: makeMedia(null, screenSizes.tablet),
  computer: makeMedia(null, screenSizes.computer),
  desktop: makeMedia(null, screenSizes.desktop),
  largescreen: makeMedia(screenSizes.desktop),
  tabletOnly: makeMedia(screenSizes.mobile, screenSizes.tablet),
  computerOnly: makeMedia(screenSizes.tablet, screenSizes.computer),
  desktopOnly: makeMedia(screenSizes.computer, screenSizes.desktop),
  aboveMobile: makeMedia(screenSizes.mobile),
  aboveTablet: makeMedia(screenSizes.tablet),
  aboveComputer: makeMedia(screenSizes.computer),
  aboveDesktop: makeMedia(screenSizes.desktop),
}

export const color = {
  primary: '#FF6B38',
  primaryOpacity27: 'rgba(255, 107, 56, .27)',
  primaryOpacity35: 'rgba(255, 107, 56, .35)',
  primaryLighten: '#F5F5F5',
  primaryLighten2: '#FDA38A',
  blackOpacity80: 'rgba(0, 0, 0, .8)',
  blackOpacity60: 'rgba(0, 0, 0, .6)',
  blackLight: '#161616',
  blackLightOpacity42: 'rgba(22, 22, 22, .42)',

  backgroundGray: '#161616',
  backgroundGrayLight: '#969696',
} as const
Enter fullscreen mode Exit fullscreen mode

Usage example:

// Component style.ts file
import styled from 'styled-components'
import { media, color } from 'ui/theme'

export const StyledWrapper = styled.div`
    position: relative;
    z-index: 1;

    background-color: ${color.white};
    border-radius: 36px;
    box-shadow: 0 10px 20px ${color.shadowPrimary2};
`

export const StyledTopGutter = styled.div`
    padding: 46px 46px 24px;

    display: flex;
    flex-flow: column wrap;

    ${media.mobile} {
        padding: 24px;
    }
`
Enter fullscreen mode Exit fullscreen mode

But it is not all, because our css code in fact is JavaScript we able to see for user-agent for determine user browser and mixin some styles for some specific browsers.

import { css } from 'styled-components'

// Works only on the client-side
// For SSR we need have some Context to Provide User-Agent from request context to React application context
const USER_AGENT = window.navigator.userAgent;

// More details about browser detect regex
// here - https://github.com/ua-parser/uap-core/blob/master/regexes.yaml
export const checkIsIE10OrOlder = /MSIE /g.test(USER_AGENT);
export const checkIsIE11 = /Trident\//g.test(USER_AGENT);
export const checkIsEdge = /Edge\//g.test(USER_AGENT);
export const checkIsFireFox = /Firefox\//gi.test(USER_AGENT);
export const checkIsChrome = /Chrome\//gi.test(USER_AGENT);
export const checkIsSafari = /Safari\//gi.test(USER_AGENT);
export const checkIsYandex = /YaBrowser\//gi.test(USER_AGENT);

export const styleIE11Browser = (...args) => checkIsIE11 ? css(...args) : null;
export const styleEdgeBrowser = (...args) => checkIsEdge ? css(...args) : null;
export const styleMicrosoftBrowsers = (...args) => checkIsIE11 || checkIsEdge || checkIsIE10OrOlder ? css(...args) : null;
export const styleIsNotMicrosoftBrowsers = (...args) => !checkIsIE11 && !checkIsIE10OrOlder ? css(...args) : null;
export const styleFireFoxBrowser = (...args) => checkIsFireFox ? css(...args) : null;
export const styleSafariBrowser = (...args) => checkIsSafari ? css(...args) : null;
export const styleYandexBrowser = (...args) => checkIsYandex ? css(...args) : null;

export const browser = {
    ie: styleMicrosoftBrowsers,
    ie11: styleIE11Browser,
    edge: styleEdgeBrowser,
    notIE: styleIsNotMicrosoftBrowsers,
    firefox: styleFireFoxBrowser,
    moz: styleFireFoxBrowser,
    safari: styleSafariBrowser,
    yandex: styleYandexBrowser,
};
Enter fullscreen mode Exit fullscreen mode

Or we able use css selectors to determine user browser.

// Works with both client-side and server-side rendering
export const isIECssDetect = (...args) => css`@media all and (-ms-high-contrast:none) {${css(...args)}}`;
export const isFireFoxCssDetect = (...args) => css`@-moz-document url-prefix() {${css(...args)}}`;

export const browser = {
    css: {
        ie: isIECssDetect,
        firefox: isFireFoxCssDetect,
        moz: isFireFoxCssDetect,
    },
};
Enter fullscreen mode Exit fullscreen mode

Usage example:

import styled from 'styled-components'
import { browser } from 'ui/theme'

export const StyledBackground = styled.img`
    position: absolute;
    object-fit: contain;
    object-position: right;
    top: 0;
    left: 0;
    z-index: -2;
    width: 100%;
    height: 100%;

    ${browser.ie`
        width: auto;
        right: 0;
        left: auto;
    `}
`;
Enter fullscreen mode Exit fullscreen mode

And CSS-in-JS very helpful for creating some base components, for example we working with custom design, in too many situations for align some element we need just element with 2 CSS props like display: flex; justify-content: center.
And in this very helpful will be ability to create small helper components like:

import styled, { css } from 'styled-components'

interface LayoutProps {
    flow: 'column' | 'row' | 'column-reverse'
    wrap?: 'wrap' | 'nowrap'
    padding?: string
    margin?: string
    justify?: 'center' | 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'stretch'
    align?: 'center' | 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'stretch'
    width?: string
    height?: string
    shrink?: string
    'data-name'?: string
    grow?: string
}

export const Layout = styled.div<LayoutProps>`
  display: flex;
  flex-direction: ${p => p.flow};
  flex-wrap: ${p => p.wrap};
  padding: ${p => `${p.padding}`};
  margin: ${p => `${p.margin}`};
  ${p => p.width && css`
    width: ${p.width}
  `};
  ${p => p.height && css`
    height: ${p.height};
  `};
  ${p => p.justify && css`
    justify-content: ${p.justify}
  `};
  ${p => p.align && css`
    align-items: ${p.align}
  `};

  ${p => p.shrink && css`
    & > * + * {
      flex-shrink: ${p.shrink};
    }
  `};
  ${p => p.grow && css`
    flex-grow: ${p.grow};
  `};
`

Layout.defaultProps = {
    wrap: 'nowrap',
    padding: '0',
    margin: '0',
    justify: undefined,
    align: undefined,
    width: '',
    height: '',
    shrink: undefined,
    'data-name': 'layout',
    grow: '',
}
Enter fullscreen mode Exit fullscreen mode

And use it:

import { Layout } from 'ui/atoms'
import { SeparateTitle } from 'ui/molecules'
import { StyledWrapper } from './style'

const OrderResponseForm: FC<Props> = () => {
    // Some code

    return (
        <Layout flow="column" wrap="wrap" margin="40px 0 0">
            <SeparateTitle line={false}>
                {i18n.t('ORDER_DETAILS_FORM_TITLE')}
            </SeparateTitle>
            <StyledWrapper
                flow="row"
                padding="24px 30px 20px 24px"
            >
                {`* Some more JSX *`}
            </StyledWrapper>
        </Layout>
    )
}
Enter fullscreen mode Exit fullscreen mode

In style.ts you have ability to extend Layout component
With saving props Type checking

export const StyledWrapper = styled(Layout)`
    border-radius: 36px;
    box-shadow: 0 4px 20px ${color.shadowBlack2};

    ${media.tablet} {
        padding: 24px;
        margin-bottom: 8px;
    }
`
Enter fullscreen mode Exit fullscreen mode

Or we can also create reusable component for text:

import styled, { css } from 'styled-components'
import {
    color as colors,
    selectWeight,
    WeightType,
} from 'ui/theme'

interface TextProps {
    align?: string
    size?: string
    color?: keyof typeof colors
    weight?: WeightType
    lineHeight?: string
    whiteSpace?: 'pre-wrap' | 'initial' | 'pre' | 'nowrap' | 'pre-line' | 'normal'
    letterSpacing?: string
    transform?: string
    'data-name'?: string
    decoration?: string
}

export const Text = styled.span<TextProps>`
    line-height: ${p => p.lineHeight};
    font-size: ${({ size }) => size};
    color: ${({ color = 'text' }) => colors[color] ? colors[color] : color};
    letter-spacing: ${({ letterSpacing }) => letterSpacing};
    text-align: ${({ align }) => align};
    text-decoration: ${({ decoration }) => decoration};
    font-weight: ${({ weight = 'normal' }) => selectWeight(weight).weight};
    white-space: ${p => p.whiteSpace};

    ${({ transform }) => transform && css`
        text-transform: ${transform};
    `}
`

Text.defaultProps = {
    align: 'initial',
    size: '14px',
    color: 'text',
    weight: 'normal',
    lineHeight: 'normal',
    whiteSpace: 'initial',
    letterSpacing: 'initial',
    decoration: 'initial',
    'data-name': 'text',
}
Enter fullscreen mode Exit fullscreen mode

CSS-in-JS raises to a new level Developer Experience (DX), because solves the problem of isolation of styles, and bring some cool features like defining attrs not in our JSX, but in the style declaration variable, it is looks like:

const StyledPrecheckInner = styled(Layout).attrs<Props>(() => ({
    flow: 'column',
    width: '100%',
}))`
    max-width: 378px;
    margin: 0 auto;

    > ${Text} {
        margin: 8px 0;
    }
`
Enter fullscreen mode Exit fullscreen mode

Or a more specific case:

export const StyledIndicator = styled.button.attrs<Props>(({
    isHasError,
    isLoading,
    isOpen,
    ...props
}) => ({
    ...props,
    type: 'button',
    children: isLoading
        ? (
            <Loader
                width="16px"
                height="16px"
                margin="0"
                inline
            />
        )
        : (
            <IconArrow
                data-dir={props.isOpen ? 'up' : 'down'}
                stroke={isHasError ? 'textDangerExtra' : 'primary'}
                width="16"
                height="16"
            />
        ),
}))`
    // CSS code
`;
Enter fullscreen mode Exit fullscreen mode

And it is support dynamic props (More examples above):

const StyledNotch = styled.div<Props>`
    height: ${p => p.isShowPlaceholder
        ? p.height
        : 'initial'}
`
Enter fullscreen mode Exit fullscreen mode

But... JS will give to us much more power, and we able to make some crazy runtime css transformations:

// A simplified example, but here you may have much more logic inside, you are limited only by JavaScript
const StyledSeparator = styled.div<Props>`
    // Some CSS

    // A function call that returns an object, or it could be a switch case
    ${({ rule }) => ({
        day: css`
            margin: 24px 0 16px;
        `,
        year: css`
            position: relative;

            width: calc(100% - 48px);
            margin: 32px 24px 16px;
        `,
    })[rule]}
`
Enter fullscreen mode Exit fullscreen mode

And it is all support typescript...

And in SRR case css in js give to us ability to generate "critical css" whick will generate css what only need for especially this page, to optimizing time what browser take on parsing our css:

// Some server setup code

server.get("/*", async (req, res) => {
  const sheet = new ServerStyleSheet();

  try {
    const app = renderToString(
      <StyleSheetManager sheet={sheet.instance}>
        <App />
      </StyleSheetManager>
    );

    const styledComponentTags = sheet.getStyleTags();

    const html = renderToStaticMarkup(
      <HtmlTemplate app={app} styledComponentTags={styledComponentTags} />
    );

    res.status(status).send(html);
  } catch (error) {
    logger.error(error);
    res.status(500).send(<ErrorPage />);
  } finally {
    sheet.seal();
  }
});
Enter fullscreen mode Exit fullscreen mode

And it is not that hard to make friends with our bundler, no matter what we use webpack, vite, or rollup and etc.
You just need some JavaScript processor like Babel, ESBuild, SWC, and etc.

It sounds really great!

But firstly, CSS-in-JS styles generated only if the component is on screen, while Sass or other CSS based solutions is included in separated css (do not consider styles in style tags), it is give to us ability to cache our css files.

And secondly... generation of css by the forces of JavaScript operation is not free, and this will eat our runtime 😒
Everything is not as bad as it sounds, styled-components for example very fast even for large projects, if you use styled-components for just static isolated styles for some element, but when use start to use too many dynamic props in reusable components it is will very fast and very noticeable slow down your application πŸ˜”

And they go on the stage Compile time CSS in JS solutions (or Zero runtime CSS in JS)

Compile time CSS-in-JS (Zero runtime CSS-in-JS)

I would single out a few players:

  • Linaria (Most popular, support React and Svelte)
  • Vanilla extract (very interesting, support more bundlers than Linaria)
  • Compiled (Compile time CSS-in-JS solution from Atlassian)

I thing from name "Compile time" you understand, what it is way to write CSS-in-JS, but without or very small runtime cost.

Linaria for example, have similar to styled components features, like components with dynamic props:

import { styled } from '@linaria/react';

const StyledTitle = styled.h1<TitleProps>`
    line-height: ${p => p.lineHeight};
    font-size: ${({ size }) => size};
    color: ${({ color = 'text' }) => colors[color] ? colors[color] : color};
    letter-spacing: ${({ letterSpacing }) => letterSpacing};
    text-align: ${({ align }) => align};
`;
Enter fullscreen mode Exit fullscreen mode

Main difference that under the hood Linaria create a wrapper component that for dynamic styles will use css-variables, it is dramatically speed up dynamic props.
More details here or Linaria docs provide guide about how it implement manually

But compilation step bring to us some limitations, like css'' fn so crazy dynamic, it is just like css scoped class.
And your style utils output is more like classes composition:

import { css, cx } from '@linaria/core';

export const selectButtonTheme = (theme: ButtonTheme, ...otherClasses: Array<string | undefined>) => {
  const cssBase = css`
    width: 170px;
    padding: 10px 0;
    display: flex;
  `

  const classes = [cssBase, ...otherClasses]

  switch (theme) {
    case 'outline':
      classes.push(css`
        border: 2px solid ${colors.primary};
      `)
      break
    case 'solid-gradient':
      classes.push(css`
        background: linear-gradient(0deg, ${colors.yellow} -80%, ${colors.orange1} 104.11%);
      `)
      break
  }

  return cx(...classes)
}
Enter fullscreen mode Exit fullscreen mode

And since you write JavaScript you also can use utility functions, but compilation step bring to us some limitations. For example i like to use absolute imports, but Linaria sometimes don't able to import my 'ui/theme' file, and to solve this problem we need to use babel-plugin-import-resolver.

{
  "presets": ["@linaria"],
  "plugins": [
    ["module-resolver", {
      "root": ["./"],
      "alias": {
        "ui/theme": "./src/ui/theme",
        "ui/keyframes": "./src/ui/keyframes"
      }
    }]
  ]
}
Enter fullscreen mode Exit fullscreen mode

But it is not all, before you will start to use Linaria, you need to configure your bundler πŸ˜„

Somehow at the interview I was asked "what i think about difficulty of configuring Webpack for Linaria, at that moment I realized, what to find a solution to set up Linaria with SSR is not simple task", but I will show you the final result for example Razzle config:

const path = require('path')
const LoadableWebpackPlugin = require('@loadable/webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  modifyWebpackConfig: ({ env: { target, dev }, webpackConfig: config }) => {
    if (target === 'web') {
      // Loadable
      config.plugins.push(
        new LoadableWebpackPlugin({
          outputAsset: false,
          writeToDisk: {
            filename: path.resolve(__dirname, 'build'),
          },
        })
      )

      // Linaria
      config.module.rules.push({
        loader: '@linaria/webpack-loader',
        options: {
          sourceMap: process.env.NODE_ENV !== 'production',
          url: false,
        },
      })

      if (!dev) {
        config.plugins.push(
          new MiniCssExtractPlugin({
            filename: 'styles.css',
          })
        )
      }

      config.module.rules = config.module.rules.map(rule => {
        if (rule.test && !Array.isArray(rule.test) && rule.test.test('some.css')) {
          rule.use = rule.use.map(use => {
            if (use.ident === 'razzle-css-loader') {
              return {
                ...use,
                options: {
                  ...use.options,
                  url: false,
                },
              }
            }
            return use
          })
        }
    }

    return config
  },
  plugins: [
    {
      name: 'typescript',
      options: {
        useBabel: true,
      },
    },
  ],
  experimental: {
    newBabel: true,
    newExternals: true,
    reactRefresh: false,
  },
}
Enter fullscreen mode Exit fullscreen mode

NextJS configuration more info here.

And you need to remember what you are tied to Babel, and even you use Vite as a bundler, you will need Babel (Vite by default use only ESBuild, to speed up bundle time). And NextJS 12 also refused Babel. It is not problem, but it is slow down build time, and, accordingly, the development experience deteriorates.

And after setup React with SSR (Razzle) as the project grows i was have some problems with HMR, when my Webpack makes Full-refresh of page, instead of just hot update styles in background. And this behavior was not permanent.

And Linaria have not so good IDE support when compared with the styled-components.

But I admire that people try to create even solutions like a Atomic Compile time CSS-in-JS, this is an amazing engineering idea 🀯

Conclusion

What approach to choose for writing application styles?

I think all depends on what kind of application we are writing.
Each approaches has its own prop and cons.
My throughs on this:

  • CSS-in-JS - choose when you don't have perfomance critical application with custom design. For example we have many "backoffice" applications what company employees use internally. CSS-in-JS in this case will give elegant and flexible API that will maximaze code readability, boost Developer Productivity and Devepment Experience.
  • CSS Modules - choose when you have some perfomance critical application. For example you are developing a personal account of a bank client which is used by millions of users. Or just E-Commerce 😝

Top comments (0)