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:
- BEM methodology
- BEM paired with Atomic Design
- Other solutions like Atomic CSS, SMACSS, and etc.
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:
- SCSS IntelliSense to have autocompletion for our SASS/SCSS variables, mixins
- SCSS Everywhere to have autocompletion for our classes in JSX/TSX files.
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:
- Vite plugin sass dts that will generate TypeScript declarations for our CSS Modules.
- Or for Webpack CSS Modules TypeScript loader
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
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;
}
`
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,
};
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,
},
};
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;
`}
`;
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: '',
}
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>
)
}
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;
}
`
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',
}
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;
}
`
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
`;
And it is support dynamic props (More examples above):
const StyledNotch = styled.div<Props>`
height: ${p => p.isShowPlaceholder
? p.height
: 'initial'}
`
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]}
`
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();
}
});
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};
`;
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)
}
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"
}
}]
]
}
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,
},
}
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 😝
Oldest comments (0)