Build-variants
Single function to create, manage, compose variants, for any CSS-in-JS libraries.
Motivation
Before diving into the implementation details, you may want to read about design considerations and motivation.
Installation
npm install build-variants
Prerequisites
Typescript is not mandatory but highly recommended. Build-variants leverages a lot
on Typescript generics and inference to provide types checking at every level.
How to use
Intanciate build-variants
In order to use build-variants with any CSS-in-JS librairies, build-variants does not
provide a CSS interface by default, meaning that your styles objects can be anything.
To provide types checking for styles, you need to pass a type/interface to the
build-variants' newBuildVariants
function.
Let's take an example with styled-components:
import { newBuildVariants } from 'build-variants'
import { CSSObject } from 'styled-components'
export function buildVariants<TProps extends object>(props: TProps) {
return newBuildVariants<TProps, CSSObject>(props)
}
Note that you can use the interface you want. Consider using React.CSSProperties
if you are doing raw React styles or your custom object definition:
import { newBuildVariants } from 'build-variants'
interface IMyStyles {
color: string
background: string
}
export function buildVariants<TProps extends object>(props: TProps) {
return newBuildVariants<TProps, Partial<IMyStyles>>(props)
}
Decorate your components
Now you can use your buildVariants
function to build styles objects that will
be passed to your styled function - most of the time.
import { buildVariants } from 'path/to/buildVariants'
import { styled } from 'styled-components'
interface Props {
_color?: 'default' | 'primary' | 'secondary'
_background?: 'default' | 'primary' | 'secondary'
_font?: Array<'default' | 'bold' | 'italic'>,
_disabled?: boolean
type?: 'default' | 'primary' | 'secondary'
}
const Div = styled.div<Props>(props => {
return buildVariants(props)
.css({
background: 'white'
})
.css({
'> button': {
all: 'unset'
}
})
.variant('_color', props._color || 'default', {
default: {
},
primary: {
color: 'white'
},
secondary: {
color: 'black'
}
})
.variant('_background', props._background || 'default', {
default: {
},
primary: {
background: 'blue'
},
secondary: {
background: 'white'
}
})
.variants('_font', props._font || [], {
default: {
},
bold: {
fontWeight: 'bold'
},
italic: {
fontStyle: 'italic'
}
})
.variant('_disabled', props._disabled || [], {
true: {
background: 'silver'
},
false: {
}
}, {
weight: 10
})
.if(
true
builder => {
return builder
.css({
color: 'pink'
})
.end()
}
)
.if(
false,
builder => {
return builder
.variant('_color', props._color || 'default', {
}
.end()
}
)
.compoundVariant('type', props.type || 'default', {
default: builder => builder.end()
primary: builder => builder
.get('_color', 'primary')
.get('_background', 'primary')
.get('_font', ['bold', 'italic'])
.end(),
secondary: builder => builder
.get('_color', 'secondary')
.get('_background', 'secondary')
.get('_font', ['bold', 'italic'])
.end()
})
.debug()
.end()
})
function ButtonComponent() {
return (
<Div type="primary" disabled>
<button>Button</button>
</Div>
)
}
Design considerations
About private and public variants
It is a proposal to manage variants with a different level of visibility but there is no obligation at all to follow this pattern.
The interesting approach with private and public variants is that you and your consumers have maximum flexibility.
Consumers are incited to use only public variants and it's recommended to communicate only on "official" and "public" variants but if a custom specific need is required, consumers can use internal variants and customize the component as their needs. It's not recommended but sometimes, pragmatism is a good thing.
About variants versus props interpolation
Variants is something relatively new in CSS-in-JS world that libraries like Stitches have made popular by adding first-class variant API support.
Stitches advocates [variants design instead of props interpolation)(https://stitches.dev/blog/migrating-from-emotion-to-stitches), meaning that variants are defined directly during the styles implementation, by infering definitions. It quicky adds complexity when it comes to extracting those variants in order to reuse them in another contexts. More generally, not having clear interfaces is rarely a good idea.
Build-variants vision is more as:
- First, define clear interfaces for your components,
- Secondly, implement your interface by defining CSS and variants,
- Optionally, compose your variants if you need more high-level behaviors (like a "primary" type that defines a bunch of styles like colors, background and borders for example).
Build-variants provides both, a first-class variant API and props interpolation, allowing to define variants according to props values.
About tokens
Tokens (strings) could be seen as a handly way to create shortcuts for complex styles definitions. But you should consider as well the drawbacks of using simple strings that can't reference the source of the implementation in addition that adding more and more aliases of styles may obfuscate a bit which styles are really applied in the end.
For values, you may want to consider importing directly what you need. If you need a custom set of styles, you can create a function and invoke it directly in styles definition.
function monospaceFontStyles(): CSSObject {
return {
fontFamily: 'monospace',
letterSpacing: '1em',
fontWeight: 500
}
}
const StyledTextArea = styled.textarea(props => {
return buildVariants(props)
.css({
color: 'black',
...monospaceFontStyles()
})
.end()
})
About global variants
Instead of importing a function to inject styles, an another option could be to
define kind of global variants used to apply styles without having to import things
and without having to define the "same" variant in various places.
To do so, you could make use of the initial the initial buildVariants
function
that defines the type to use for styles. Just add some variants definitions here
and expose an interface that you can use when styling your components.
export type ExtendedStyledProps<TProps extends object> = TProps & {
font?: 'default' | 'monospace'
}
export function buildVariants<
TProps extends object,
TExtendedProps extends ExtendedStyledProps<TProps>
>(props: TExtendedProps) {
return newBuildVariants<TExtendedProps, CSSObject>(props).variant(
'font',
props.font || 'default',
{
default: {
},
monospace: {
fontFamily: 'monospace',
letterSpacing: '0.1em',
fontWeight: 5000
}
}
)
}
Now, when styling a component, you can use the extended interface to expose the
global variants:
const StyledTextArea = styled.textarea<
ExtendedStyledProps<HTMLAttributes<HTMLTextAreaElement>>
>(props => {
return buildVariants(props)
.css({
color: 'black'
})
.end()
})
() => <StyledTextArea maxLength={50} font="monospace" value="Hello World" />
Have fun building variants! :)
v1.2.0 (2022-11-14)
Changed
-
Prop values passed to variant(s)
and compoundVariant(s)
are now optional.
It allows to not apply default styles or having to create "empty variant" for default cases.
-
Private variants (prop starting by _
) now overrides composed variants got from an existing variant declaration.
For example, let's consider a Button on which the "primary" variant define a white color, if a private variant is defining a different color, it's possible to override the primary color like this:
<Button variant="primary" _color="red" />