February 20, 2021
Disclaimer: This post reflects my exploration of and personal experiences with CSS-in-JS as a React TypeScript developer.
CSS-in-JS (or CiJ) refers to the technique of using JavaScript to write styles to generate CSS in a declarative and maintainable way.
These CiJ libraries aim to solve a variety of limitations of traditional CSS by providing benefits such as:
This mostly comes at a performance trade-off since your styles are now computed at runtime, may possibly block the initial paint of your components, and will increase your client's JavaScript bundle.
There are numerous CSS-in-JS options to select from. Some may work better for you than others depending on your preferences and use-case.
Below are some of the things that factored into my decisions on CSS-in-JS.
There is a very real performance hit to the mounting and re-render times of React components when using CSS-in-JS versus not using any.
Example taken from A-gambit's CSS-IN-JS-BENCHMARKS
Solution | Use CSS | Use Inline-Styles | Mount Time (ms) | Rerender time (ms) |
---|---|---|---|---|
react (without styles) | - | - | 17.25 | 39.11 |
react (with inline-styles) | - | + | 32.8 | 51.68 |
styled-jsx-inline-styles | + | + | 40.67 | 54.32 |
emotion-css-mode | + | + | 40.2 | 54.91 |
cxs | + | + | 39.87 | 55.28 |
react-css | + | + | 39.3 | 55.39 |
aphrodite | + | + | 42.25 | 55.48 |
glam-inline-style | + | + | 41.75 | 57.22 |
glam-simple | + | - | 42.67 | 72.69 |
merge-styles | + | - | 59.2 | 75.77 |
styled-components-inline-styles | + | + | 97.6 | 78.03 |
styled-jss-w-o-plugins-v2 | + | - | 119.9 | 78.11 |
styled-jss-w-o-plugins | + | - | 122.6 | 78.53 |
rockey-inline | + | + | 98.53 | 79.01 |
fela | + | - | 75.52 | 84.82 |
styletron | + | - | 98.6 | 85.69 |
radium | - | + | 99.47 | 89.19 |
styled-jss-v2 | + | - | 165.4 | 91.07 |
styled-jss | + | - | 166.05 | 93.51 |
emotion-extract-static | + | + | 126.33 | 93.66 |
emotion-decouple | + | - | 142.85 | 96.36 |
emotion-simple | + | - | 138.08 | 96.94 |
styled-jsx-dynamic | + | - | 155.1 | 122.89 |
react-native-web | + | + | 238.72 | 140.16 |
styled-components | + | - | 182 | 146.84 |
styled-components-decouple-cell | + | - | 213.53 | 152.39 |
rockey-speedy | + | - | 114.3 | 187.27 |
rockey | + | - | 213.32 | 274.72 |
glamorous | + | - | 205.7 | 283.68 |
react-jss | + | - | 198.97 | 297.74 |
glamorous-glamour-css | + | - | 278.7 | 407.91 |
sorted by rerender time
Looking at these benchmarks, you can see that Styled-Components and JSS, two very popular options within CiJ, are seeing increases of around x10 in the time it takes to mount/re-render compared to React without styles.
However, while the increases in times appear significant, I haven't felt that sites such as Reddit, AirBnB or Github, which all use Styled-Components, to feel less-usable as a result of increased render times.
I value these as a TypeScript developer, so if a library has lackluster intellisense, it will rank lower on my preferences.
Many well-maintained CiJ libraries ship with TypeScript definitions (examples: Styled-Components, JSS, Emotion, Aphrodite). So any use of styled
will produce components with correctly typed props and any className
generating APIs (e.g. css
, createUseStyles
, etc.) will return strings to be usable with your components' className
props.
Example of Styled-Components
1import React from "react";2import { styled } from "styled-components";34export interface ButtonProps {5 size: "small" | "large";6}78export const Button = styled.button<ButtonProps>`9 width: ${({ size }) => {10 switch (size) {11 case "small":12 return "80px";13 default:14 return "200px";15 }16 }}17`;1819// Prop types are the union of HTMLButtonElement props and ButtonProps20Button.defaultProps = {21 size: "small"22};
Example of React-JSS
1import { Theme } from "./theme";2import clsx from "clsx";3import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react";4import { createUseStyles } from "react-jss";56export interface ButtonProps extends7 DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {8 size: "small" | "large";9}1011const styles = (theme: Theme) => ({12 root: {13 /* Unfortunately, untyped props from JSS */14 width: ({ size }) => {15 switch (size) {16 case "small":17 return "80px";18 default:19 return "200px";20 }21 }22 }23});2425const useStyles = createUseStyles<Theme, keyof ReturnType<typeof styles>>(styles);2627const Button: FC<ButtonProps> = ({ className, size, ...restButtonProps }) => {28 /* classes will be typed with all class names */29 const classes = useStyles({ size });3031 return <button {...restButtonProps} className={clsx(classes.root, className)} />;32};
The styled
syntax makes use of tagged template literals so that developers can write real css syntax, with interpolated values and functions in JavaScript. Since you're writing styles in a string, you may not have syntax highlighting or auto-completion in your IDE.
Thankfully, if you use VSCode like me, there is a bit of set-up you can do to get syntax highlighting and auto-completion working for your styled
and css
template literals, regardless of which CiJ you're using (as long as they are using this style of tagged template literals).
Ctrl + P
, and enter ext install vscode-styled-components
typescript-styled-plugin
1npm install --save-dev typescript-styled-plugin
tsconfig.json
1{2 "compilerOptions": {3 "plugins": [4 {5 "name": "typescript-styled-plugin"6 }7 ]8 }9}
Simply put, being able to write real CSS is nice if you've already learned CSS or SCSS. In addition, we saw above that being able to write real CSS allows us to leverage IDE tooling such as syntax-highlighting and auto-completion from our IDE.
While CSS as JSON relieves you from needing to learn the syntax for CSS, you miss out on the benefits above which loses out on some DX.
However, it is not unusual to want to compose a CSS fragment expressed as JSON. Maybe the CSS fragment was derived from a JavaScript function such as those from polished.js.
So being able to do both is a huge benefit to DX.
Example of Styled-Components
1import { styled } from "styled-components";23const borderStylesFragment = {4 border: "1px solid black";5 borderRadius: 46};78export const Button = styled.button`9 font-size: 0.875rem;10 color: green;1112 ${borderStylesFragment};13`;
Disclaimer: This section is purely my own preference, and something I do in my own projects. It's much simpler to adhere to a single convention in any shared project with multiple developers than do what I am about to suggest here:
There are many benefits with using the styled
API. The Styled-Components docs list many of the benefits there.
Take these examples of using styled
vs className
:
Example of Styled-Components
1import { styled } from "styled-components";23export const Button = styled.button`4 background-color: blue;5`;
Example of cxs
1import clsx from "clsx";2import { cxs } from "cxs";3import React, { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react";45const classes = {6 root: cxs({7 backgroundColor: "blue"8 })9};1011export const Button = forwardRef<12 HTMLButtonElement,13 DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>14>(({ className, ...restButtonProps }, ref) => {15 return (16 <button17 {...restButtonProps}18 ref={ref}19 className={clsx(classes.root, className)}20 />;21});2223Button.displayName = "Button";
In this simple example, the styled
example certainly looks much more attractive than the className
example, as it's much more declarative.
However, I like using a mix of styled
and className
in my own projects. So I like selecting a library that gives me the flexibility to do either or both.
Example using all styled components
1/* ... */23const CustomerDataTable: FC = ({ onClose }) => {4 /* ... */56 return (7 <Wrapper>8 <HeaderWrapper>9 <TitleWrapper>10 <Typography as="h1">Customers</Typography>11 </TitleWrapper>12 <CloseButton onClick={onClose}>Close</CloseButton>13 </HeaderWrapper>14 <DataTableWrapper>15 <DataTable customers={customers} />16 </DataTableWrapper>17 <ActionsWrapper>18 <DeleteButton>Delete</DeleteButton>19 <EditButton>Edit</EditButton>20 </ActionsWrapper>21 </Wrapper>22 );23};
Example using a mix of styled components and classNames
1/* ... */23const CustomerDataTable: FC = ({ onClose }) => {4 /* ... */56 return (7 <div className={classes.wrapper}>8 <div className={classes.headerWrapper}>9 <div className={classes.titleWrapper}>10 <Typography as="h1">Customers</Typography>11 </div>12 <Button className={classes.closeButton}>Close</Button>13 </div>14 <div className={classes.dataTableWrapper}>15 <DataTable customers={customers} />16 </div>17 <div className={classes.actionsWrapper}>18 <Button className={classes.deleteButton}>Delete</Button>19 <Button className={classes.editButton}>Edit</Button>20 </div>21 </div>22 );23};
In shared projects, I would stick to the first example of using all shared components, because it is easier to create a convention to adhere to. But in my own projects, I tend to prefer the second, mixed example because I find it easier to parse for myself.
Any JSX.IntrinsicElement
such as div
with a className
can be assumed to be single-use and non-complex. Anything else is either a reusable atom, molecule or organism from my atomic design system, and may or may not be complex but warrants deeper understanding than a JSX.IntrinsicElement
.
It also helps with parsability that my IDE's theme will color JSX.IntrinsicElement
s a different color than custom components from my atomic design system.
There are other factors I considered for which CiJ library I decided to use in my projects, such as:
In the end, I've selected Linaria for my projects for the reasons below:
styled
and css
(className
) API, with TypeScript support, syntax highlighting and auto-completion.Some cons to my decision include:
Linaria works really well for my preferred DX and use-cases.
There's no objective "best" tool. As long as your projects' styles are more maintainable and your developers are generally content with the DX, that's a winning decision.
Happy coding! -- David Lee