Building a React Component Library - Part 1
Introduction
![cover image](/assets/CJaegmYR-cover.webp)
When it comes to React component libraries, there's no shortage of excellent options to choose from. Popular libraries like material-ui
, react-spectrum
, fluentui
, and polaris
have already earned their place as favorites among developers. Nevertheless, as I plan for an upcoming website project, I can't shake the idea of creating my own library from scratch. In this blog post, I'll explain how and why I made certain technical decisions for my new library, wtlin-ui
.
These decisions include choosing Emotion as the style engine, implementing theme-based system CSS props and sx
Prop with the Styled System library, and providing theme colors via CSS custom properties to enable Color Mode without the dreaded flash of unstyled content.
Emotion
as the style engine
Choose When it comes to styling frontend applications, there are several popular options available, including CSS, SASS, CSS-in-JS, and zero-runtime libraries. As I considered my choices, I narrowed my focus to two CSS-in-JS libraries (styled-components and Emotion) and two zero-runtime libraries (Linaria
and vanilla-extract
).
Out of these four options, styled-components
is undoubtedly one of the most well-established and widely used libraries, with extensive documentation. However, I ultimately decided to use Emotion
instead. Emotion
has a smaller bundle size and offers superior performance and more flexibility, making it the ideal choice for my project. In fact, the Storybook and MUI teams both recommend using Emotion
for various use cases.
Linaria
was another library that caught my attention. This zero-runtime CSS-in-JS library extracts CSS to standalone CSS files during build time, eliminating the need for runtime CPU overhead. This approach also offers caching benefits and makes it possible to deduplicate styles using Atomic CSS CSS. However, the lack of top-notch documentation and dedicated website, combined with the trial-and-error process of finding information, ultimately led me away from choosing this library.
Finally, I considered vanilla-extract
, a modern solution with excellent TypeScript integration and no runtime overhead. While its minimal features, straightforwardness, and opinionated nature appealed to me, the fact that it processes everything at compile time and generates static CSS files was not enough to overcome the significant downside of code co-location.
Ultimately, after careful consideration, I decided to go with Emotion
for its superior performance, flexibility, and overall suitability for my project's needs.
sx
prop with Styled System
Add theme-based system CSS props and System CSS properties and the sx prop are becoming increasingly popular. Many frameworks, like material-ui
, support them nowadays. I personally use them as they simplify components and prevent developers from jumping back and forth to check trivial layout styles.
Writing code like this whenever you want to retrieve values from the theme can become tedious:
const Foo = () => {
const theme = useTheme()
return (
<Box
mt={theme.sizing * 2}
>
Foo
</Box>
)
}
The theme-based Style Props
Style functions of styled-system
will try to find a value from the theme object, even for deeply nested values, and fallback to a hard-coded value if they can't.
// font-size: 24px (theme.fontSizes[4])
<Box fontSize={4} />
// margin: 16px (theme.space[3])
<Box m={2} />
// color: #333 (theme.colors.blacks[0])
<Box color="blacks.3" />
// background color (theme.colors['light-red'])
<Box bg="light-red" />
// line-height: 1.5 (theme.lineHeights.copy)
<Box lineHeight="copy" />
// renders CSS `50%` width since it's not defined in the theme
<Box width={1/2} />
sx
Prop
The Define the type SxProp
and the style function sx
:
// sx.ts
import css, { SystemStyleObject } from "@styled-system/css";
export interface SxProp {
sx?: SystemStyleObject;
}
const sx = (props: SxProp) => css(props.sx);
export default sx;
Next, combine it with styled
API provided by emotion
(or styled-component
):
import styled from "@emotion/styled";
import sx, { SxProp } from "./sx";
type StyledBoxProps = SxProp;
const Box = styled.div<StyledBoxProps>(sx);
Now, the Box
component can accept sx
prop and be used like this:
<Box
sx={{
mt: "2",
p: "1",
bg: "scale.red.3",
color: "common.white",
}}
>
the sx prop works!
</Box>
While the sx
prop is powerful, it's important not to abuse it. Here are some guidelines:
-
Use the
sx
prop for small stylistic changes to components. For more substantial changes, consider abstracting your style changes into your own wrapper component. -
Avoid nesting and pseudo-selectors in
sx
prop values when possible.
The System CSS Props
Suppose I want my Box
component to accept not only the sx
prop but also the space
and typography
CSS props. In that case, it can be done easily with styled-system
:
import {
space,
SpaceProps,
typography,
TypographyProps,
} from "styled-system"
type StyledBoxProps =
SpaceProps &
TypographyProps &
SxProp
const Box = styled.div<StyledBoxProps>(
space,
typography,
sx
)
Now, my Box
component can accept spacing and typography props like this:
<Box
mt={2}
p={1}
fontSize={3}
>
the system CSS props works!
</Box>
Enable Color Mode with CSS custom properties
The renderToString
method renders a React tree to HTML string, including the styles() that are also rendered to string on the server-side. However, as there is no way to know the user's preference for color mode (apart from using a session), styles() are rendered with the default color mode, which is typically the light mode. This can result in two significant problems:
- the mismatching of content during hydration
- the flash of incorrectly styled content (which may not be critical but can still be problematic)
Fortunately, these issues can be resolved by taking advantage of CSS custom properties and an inline script.
const lightColors = {
"--wtlin-ui-colors-text": "white",
"--wtlin-ui-colors-bg": "black",
};
const darkColors = {
"--wtlin-ui-colors-text": "black",
"--wtlin-ui-colors-bg": "white",
};
const GlobalStyle = css({
html: {
...lightColors,
},
"html.wtlin-ui-light": lightColors,
"html.wtlin-ui-dark": darkColors,
});
const Body = styled.div({
color: "var(--wtlin-ui-colors-text)",
background: "var(--wtlin-ui-colors-bg)",
});
<Script id="set-color-mode" strategy="beforeInteractive">
{`
try {
let mode = localStorage.getItem('wtlin-ui-color-mode');
if (!mode) {
const preferDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
mode = preferDarkMode ? 'dark' : 'light';
localStorage.setItem('wtlin-ui-color-mode', mode)
}
if (mode) {
document.documentElement.classList.add('wtlin-ui-' + mode);
}
} catch (e) {}
`}
</Script>
CSS-in-JS without CSS custom properties
server client
│ │
│ /home │
┌───┤◄────────────────┤
│ │ │
│ │ │
SSR └──►├────────────────►│
(light mode) │ ├───┐
│ /bundle.js │ │ Construct DOM
│◄────────────────┤ │
│ │◄──┘
├────────────────►│
│ ├───┐
│ │ │ Construct CSSOM
│ │ │
│ │◄──┘
│ │
│ ├─ ─ ─ render page with ─ ─
│ │ light mode │
│ ├───┐ │
│ │ │ Execute JS │
│ │ │ hydrate with Flash!!
│ │ │ dark mode │
│ │ │ (mismatch ERROR ❌)│
│ │◄──┘ │
│ │ ▼
│ ├─ ─ ─ re-render with ─ ─ ─
│ │ dark mode
│ │
│ │
│ │
─┴─ ─┴─
CSS-in-JS with CSS custom properties
server client
│ │
│ /home │
┌───┤◄────────────────┤
SSR │ │ │
│ │ │
└──►├────────────────►│
│ ├───┐
│ /bundle.js │ │ Construct DOM
│◄────────────────┤ │ Execute inline script
│ │◄──┘
├────────────────►│
│ ├───┐
│ │ │ Construct CSSOM
│ │ │
│ │◄──┘
│ │
│ ├─ ─ ─ render page with ─ ─
│ │ dark mode │
│ ├───┐ │
│ │ │ Execute JS │
│ │ │ hydrate with No Flash!!
│ │ │ dark mode │
│ │ │ (no ERROR ✅) │
│ │◄──┘ │
│ │ ▼
│ ├─ ─ ─ re-render with ─ ─ ─
│ │ dark mode
│ │
│ │
│ │
─┴─ ─┴─
Styled System with CSS custom properties
To make the theme clean and avoid being messed up by CSS custom properties, I created two helper functions: flatWithPath
and toCssCustomProperties
.
With these helper functions, I can define the theme in the same way as before:
const primitives = {
garden: {
flower: "red",
stone: ["blue", "green", "yellow"],
animal: {
cat: "black",
dog: "pink",
rabbit: ["white", "cream"],
},
},
bag: ["blue", "red", "yellow"],
hair: "brown",
};
The flatWithPath
function is used to apply the styles to the HTML using CSS custom properties, while the toCssCustomProperties
function enables the use of styled-system
with CSS custom properties.
Here's an example of how these functions can be used:
it("flats colors", () => {
expect(flatWithPath(primitives, "--wtlin-ui-colors")).toStrictEqual({
"--wtlin-ui-colors-garden-flower": "red",
"--wtlin-ui-colors-garden-stone-0": "blue",
"--wtlin-ui-colors-garden-stone-1": "green",
"--wtlin-ui-colors-garden-stone-2": "yellow",
"--wtlin-ui-colors-garden-animal-cat": "black",
"--wtlin-ui-colors-garden-animal-dog": "pink",
"--wtlin-ui-colors-garden-animal-rabbit-0": "white",
"--wtlin-ui-colors-garden-animal-rabbit-1": "cream",
"--wtlin-ui-colors-bag-0": "blue",
"--wtlin-ui-colors-bag-1": "red",
"--wtlin-ui-colors-bag-2": "yellow",
"--wtlin-ui-colors-hair": "brown",
});
});
it("maps colors to CSS custom properties", () => {
expect(toCssCustomProperties(primitives, "--wtlin-ui-colors")).toStrictEqual({
garden: {
flower: "var(--wtlin-ui-colors-garden-flower)",
stone: [
"var(--wtlin-ui-colors-garden-stone-0)",
"var(--wtlin-ui-colors-garden-stone-1)",
"var(--wtlin-ui-colors-garden-stone-2)",
],
animal: {
cat: "var(--wtlin-ui-colors-garden-animal-cat)",
dog: "var(--wtlin-ui-colors-garden-animal-dog)",
rabbit: [
"var(--wtlin-ui-colors-garden-animal-rabbit-0)",
"var(--wtlin-ui-colors-garden-animal-rabbit-1)",
],
},
},
bag: [
"var(--wtlin-ui-colors-bag-0)",
"var(--wtlin-ui-colors-bag-1)",
"var(--wtlin-ui-colors-bag-2)",
],
hair: "var(--wtlin-ui-colors-hair)",
});
});
With these functions, the theme is kept clean and separate from the use of CSS custom properties, making it easier to maintain and modify the theme as needed.