Variant-driven Components

Variants are an excellent way to express how a component could look. It allows us to separate our design concerns into isolated rules.

Variants can be immutable, responsive, and composed. It enables a great deal of flexibility and power when building UIs.

This article will demonstrate how you can build variant-driven components using Stitches.


Context

Since joining Modulz almost three years ago, I've been exploring the idea of variant-driven components. The Modulz editor was designed with variants in mind, and so was the production-ready code output.

At the time, we were creating faux variants on top of existing styling solutions. However, the result wasn't ideal. We needed an interoperable solution, so both designers and developers could work on the same source of truth.

Stitches

Stitches is a styling solution focusing on component architecture and developer experience.

It introduces a first-class variant API, enabling design system authors to express their intent better. It's fully typed, catching potential mistakes and improving the scalability of design systems. It's lightweight, coming in at less than 5kb. And finally, it's a breeze to get up and running with it.

This article

To highlight how variants can be created and consumed, I'll use a generic Box component.

If you'd like to follow along, make sure you've installed Stitches:

# yarn
yarn add @stitches/react
# npm
npm i @stitches/react

Creating a component

To create the Box component, first, import the styled function from Stitches:

import { styled } from '@stitches/react';

Then use it to create a component:

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
backgroundColor: 'pink',
});

Finally, render it just like you would any React component:

<Box />

This is how you create and style components with Stitches. To learn more about it, go have a look at the documentation site.

Creating a variant

Stitches offers a first-class API for creating variants. Because of this, they're automatically typed.

Let's add a color variant to the Box.

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
backgroundColor: 'pink',
variants: {
color: {
turquoise: {
backgroundColor: 'turquoise',
},
},
},
});

We can render it at that specific variant by passing it as a prop.

<Box color="turquoise" />

Nice. We can go further and add a shape variant:

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
backgroundColor: 'pink',
variants: {
color: {},
shape: {
round: {
borderRadius: '100%'
}
}
},
});

Now, we can apply these variants as props:

<Box color="turquoise" shape="round" />

Separating concerns

Let's take a step back and review our code so far.

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
backgroundColor: 'pink',
variants: {
color: {
turquoise: {
backgroundColor: 'turquoise',
},
},
shape: {
round: {
borderRadius: '100%',
},
},
},
});

Notice how currently there are some base styles and some variant styles. This creates some implicit variants. Let me explain...

To make it turquoise, you must explicitly pass it as a color prop. But the same isn't true for pink, because there's no such variant.

For example, imagine we want to apply the color variant based on a condition.

const App = ({ active }) => <Box color={active ? 'turquoise' : undefined} />;

This would work, but we can do better—by being explicit:

const App = ({ active }) => <Box color={active ? 'turquoise' : 'pink'} />;

We can achieve this by separating stylistic concerns into variants.

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px', height: '70px', - backgroundColor: 'pink',
variants: { color: { + pink: { + backgroundColor: 'pink', + }, turquoise: { backgroundColor: 'turquoise', }, }, shape: { + square: { + borderRadius: 0 + }, round: { borderRadius: '100%', }, }, }, });

Variant composition

Now that we have a few variants, we can mix and match (compose) them. This is when things start to get interesting!

<Box color="pink" />
<Box color="turquoise" />
<Box color="pink" shape="round" />
<Box color="turquoise" shape="round" />

Defining a default variant

When you separate your stylistic concerns into variants, you need to explicitly tell your component which variant to use.

<Box color="pink" shape="square" />

But you can define default variants, essentially mimicking the base styles behaviour—without compromises.

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
variants: {
color: {
pink: {},
turquoise: {}
},
shape: {
square: {},
round: {},
},
},
defaultVariants: {
color: 'pink',
shape: 'square'
}
});

Then, use it

<Box />

Responsive styles

Currently our Box is 70px. But let's say we'd like to make it responsive by increasing its size to 140px when the viewport is wider than 1000px.

A common approach to this is adding media queries in the styles:

import { styled } from '@stitches/react';
const Box = styled('div', {
width: '70px',
height: '70px',
'@media (min-width: 1000px)': {
width: '140px',
height: '140px',
},
variants: {},
defaultVariants: {}
});

This fulfils our initial requirement. The box gets bigger when it needs to.

But hang on. What if now we'd like to be able to render the Box at 140px on mobile? And what if it needs to be 70px on larger viewports?

You see where this is heading, right?

We can do better. With immutable variants.

Immutable variants

The idea behind immutable variants is simple. Styles should be the same across all media queries. As demonstrated above, when you add responsive logic inside your component styles, you're making a definite decision of how your component should behave.

Another approach would be to convert all the available sizes that the Box can exist into variants.

We'll do that by creating a size variant.

import { styled } from '@stitches/react';
const Box = styled('div', {
- width: '70px', - height: '70px',
- '@media (min-width: 1000px)': { - width: '140px', - height: '140px', - },
variants: { color: {…}, shape: {…}, + size: { + small: { + width: '70px', + height: '70px', + }, + large: { + width: '140px', + height: '140px', + } + } },
defaultVariants: {…} });

Notice how the @media rule is gone. This component is no longer in charge of when to change from small to large. It just knows that it can be rendered in either one.

So how do we add the responsive behaviour back in?

Via props! This happens at the consumption layer rather than the authoring layer. It looks like this:

<Box size={{ '@initial': 'small', '@media (min-width: 1000px)': 'large', }} />

With this pattern, you're in control over when to use a specific variant.

Want to start with large and then switch to small? No problem.

<Box size={{ '@initial': 'large', '@media (min-width: 1000px)': 'small', }} />

You can change as many variants as you need:

<Box size={{ '@initial': 'small', '@media (min-width: 1000px)': 'large', }} color={{ '@initial': 'pink', '@media (min-width: 1000px)': 'turquoise', }} shape={{ '@initial': 'square', '@media (min-width: 1000px)': 'round', }} />

Compound variants

Let's say that we want to add a glow effect. But we want it to match color variant.

In order for us to achieve this, we need to set styles based on a specific combination of variants. And we can do that with the compound variant API.

First, let's create a isGlowing boolean variant:

import { styled } from '@stitches/react';
const Box = styled('div', {
variants: {
color: {},
shape: {},
size: {},
isGlowing: {
true: {
boxShadow: '0 0 20px black'
}
}
},
defaultVariants: {}
});

It can be applied like this:

<Box isGlowing />
<Box color="turquoise" isGlowing />
<Box isGlowing shape="round" />
<Box color="turquoise" shape="round" isGlowing />

Now we can change the glow's color based on the color variant.

import { styled } from '@stitches/react';
const Box = styled('div', {
variants: {
color: {},
shape: {},
size: {},
isGlowing: {
true: {
boxShadow: '0 0 30px black'
}
}
},
defaultVariants: {},
compoundVariants: [{
color: 'pink',
isGlowing: true,
css: {
boxShadow: '0 0 30px pink'
}
}, {
color: 'turquoise',
isGlowing: true,
css: {
boxShadow: '0 0 30px turquoise'
}
}]
});

Let's render it:

<Box isGlowing />
<Box color="turquoise" isGlowing />
<Box isGlowing shape="round" />
<Box color="turquoise" shape="round" isGlowing />

That's pretty cool, right? This is a powerful pattern and opens up a whole world of opportunities.

Tidying up

Let's take 2 minutes to review and refactor the code a little.

Since there are no boxShadow shorthands to change just its color, we have to re-define the whole shadow config twice.

We can take advantage of Stitches' locally-scoped tokens to make our code more DRY.

import { styled } from '@stitches/react';
const Box = styled('div', {
variants: { color: {…}, shape: {…}, size: {…}, isGlowing: { true: { + $$shadowColor: 'transparent', - boxShadow: '0 0 30px black' + boxShadow: '0 0 30px $$shadowColor' } } },
defaultVariants: {…},
compoundVariants: [{ color: 'pink', isGlowing: true, css: { - boxShadow: '0 0 30px pink' + $$shadowColor: 'pink' } }, { color: 'turquoise', isGlowing: true, css: { - boxShadow: '0 0 30px turquoise' + $$shadowColor: 'turquoise' } }] });

All together now

And finally, here's the final code.

import { styled } from '@stitches/react';
const Box = styled('div', {
variants: {
color: {
pink: {
backgroundColor: 'pink',
},
turquoise: {
backgroundColor: 'turquoise',
},
},
shape: {
square: {
borderRadius: 0,
},
round: {
borderRadius: '100%',
},
},
size: {
small: {
width: '70px',
height: '70px',
},
large: {
width: '140px',
height: '140px',
},
},
isGlowing: {
true: {
$$shadowColor: 'transparent',
boxShadow: '0 0 30px $$shadowColor',
},
},
},
defaultVariants: {
color: 'pink',
shape: 'square',
size: 'small',
},
compoundVariants: [
{
color: 'pink',
isGlowing: true,
css: {
$$shadowColor: 'pink',
},
},
{
color: 'turquoise',
isGlowing: true,
css: {
$$shadowColor: 'turquoise',
},
},
],
});

And here are all the possible variations.

Conclusion

Thinking about variant-driven components can have a tremendous positive effect on your product. Not only does it enforce a more maintainable codebase, it can also improve communication across your team.

By organising your styles in variants, you're separating its stylistic concerns. This results in fewer unwanted side effects.

When you combine variants, you embrace composition. This allows for powerful UI variations.

And whenever you need a special rule for a given composition, the compound variants functionality is there to help.

As you may be able to tell, I'm very passionate about this topic. I'm glad to have spent so long thinking about variants! Some of my early explorations have contributed to the Styled System/Theme UI variant API. And Stitches has inspired other libraries such as Twind and vanilla-extract to follow suit.

To learn more about building variant-driven components with Stitches, check out the docs.

✌️ Thanks for reading.

Shoutout to Benoît Grélard for proofreading this article.


Share this post on Twitter