Theme system is a libary that combines the benefits of utility class libraries like https://tailwindcss.com/ with the DX of css-in-js libraries like Styled Components or Emotion combined with styled-system.
At Reversed we make heavy use of both styled-components and styled-system. It offers a great DX but comes at a cost, since each Box
component and styled.div
call adds bytes to the JS bundle and these styles are inserted during runtime. We've looked for alternatives and found Linaria which looks great. It's mainly the same API as styled-components, but extracts styles to static css. On the other hand we've looked at https://tailwindcss.com/, which is gaining a lot of attention and rightfully so.
We didn't want to lose our Box
component though and wanted the freedom of our own design system. So we've build Theme System. Theme-System combines best of both worlds; it creates a fixed set of utility-clases for maximum reusability with the DX of styled-system props.
Run yarn add theme-system@latest
For a working example with next.js & linaria, please check the examples folder.
Create a theme system config file, for example theme-system.ts
, inside this file, create the utility classes and parser using the createThemeSystem
function. The createThemeSystem
function accepts a single theme object and return an object with three properties.
Your theme object should look like this:
type Theme = {
breakpoints: { [key: string]: string | number }
fontWeights: { [key: string]: string | number }
fontFamilies: { [key: string]: string | number }
space: { [key: string]: string | number }
colors: { [key: string]: string | number }
}
Example:
// theme-system.ts
const theme = {
breakpoints: {
md: '1024px',
},
fontWeights: {
regular: '400',
},
fontSizes: {
large: '3rem',
regular: '1rem',
},
fontFamilies: {
heading: 'serif',
body: 'sans-serif',
},
space: {
'0': 0,
'1': '1rem',
'2': '2rem',
},
colors: {
primary: '#236FEA',
info: '#258AE7',
success: '#27C62D',
},
}
export type Theme = typeof theme
export const { utilities, parse, parseAll } = createThemeSystem<Theme>(theme)
Note the
export type Theme = typeof theme
line, this is needed to have typechecking on your theme object.
The utilities
property is a string containing the css with the utility classes, you should add this once inside your global css.
The parse
& parseAll
functions are identical in implementation, but parse
is strictly typed based on your theme.
The parse function allows you to generate a string of classnames based on your theme which you can use anywhere in your code. Each property can be one of the values of that property in your theme (e.g. color: 'primary'
) or an object with one of your breakpoint keys as the key, or _
for the initial style. For example:
<p
className={parse({
color: 'primary',
fontSize: 'large',
fontFamily: {
_: 'body',
md: 'heading',
},
})}
>
Using parse
</p>
Renders this HTML:
<p class="font-family-body md-font-family-heading font-size-large color-primary">Using parse</p>
The parse
function is strictly typed, so passing in invalid properties will result in a typescript.
The parseAll
function allows you to pass in a props object without strict type checking. This allows you to build custom components with typechecking on their props and passing a complete props object to parseAll
, generating the right class names. A Box component example:
import React, { FC, HTMLAttributes } from 'react'
import { ThemeSystemProps, filterProps } from 'theme-system'
import { Theme, parseAll } from '../lib/theme-system'
import { cx } from 'linaria'
type Props = HTMLAttributes<HTMLDivElement> & ThemeSystemProps<Theme> & {}
const Box = React.forwardRef<HTMLDivElement, Props>(({ className, children, ...props }, ref) => {
return (
<div className={cx(className, parseAll(props))} ref={ref} {...filterProps(props)}>
{children}
</div>
)
})
export default Box
In this example, the filterProps
helper removes all theme system related props from the object, preventing your div having html attributes like color
or height
in the DOM.
Using the Box component, a component that looks like this:
<Box color="primary" fontSize="large" fontFamily={{ _: 'body', md: 'heading' }}>
Using a Box component
</Box>
Will render output like this:
<div class="font-family-body md-font-family-heading font-size-large color-primary">Using parse</div>
Both parse
& parseAll
support passing an additional classname or as the second argument. For example: parse({color:primary}, 'additional-class')
will output color-primary additional-class
.
Important: the classnames are hashed in production, don't use them directly.
This needs a little more documentation work but these props are enabled:
Prop | Value |
---|---|
mt | theme.space |
mr | theme.space |
mb | theme.space |
ml | theme.space |
mx | theme.space |
my | theme.space |
m | theme.space |
pt | theme.space |
pr | theme.space |
pb | theme.space |
pl | theme.space |
px | theme.space |
py | theme.space |
p | theme.space |
Prop | Value |
---|---|
display | 'inline', 'block', 'flex', 'grid', 'inline-block', 'none', 'initial' |
width | '100%' , 'auto' , 'screen' |
minWidth | '100%' , 'auto' , 'screen' |
height | '100%' , 'auto' , 'screen' |
minHeight | '100%' , 'auto' , 'screen' |
Prop | Value |
---|---|
fontFamily | theme.fontFamilies |
fontWeight | theme.fontWeights |
fontSize | theme.fontSizes |
color | theme.colors |
textAlign | 'left', 'center', 'right' |
Prop | Value |
---|---|
bg | theme.colors |
Prop | Value |
---|---|
alignItems | 'center', 'flex-start', 'flex-end' |
justifyContent | 'center', 'flex-start', 'flex-end', 'space-between', 'space-around' |
flexWrap | 'wrap' ,'wrap-reverse' |
- Allow variants
- See if we can use purgeCSS to remove unused utilities
- Investigate a babel-plugin to compile away the parse & parseAll calls
Note that this is expiremtal but ideas and pr's are welcome. Theme System is based on TSDX, check that out if you'd like to contribute.