This past year, I started learning a lot about the value of design systems. At previous jobs as a web developer, I would often use libraries like Material UI to bring to life work from design teams. Now that I was 3 years into it and had experienced different companies' workflows, I wanted to take the time to slow down a bit - create something of my own, and optimize for learning. In doing so, I discovered the joy of design engineering.
It actually took me a while to understand the pros and cons of using pre made design systems exposed by widely adopted component libraries in the industry. I get why they are used - the main reason being speed. On any given project, if I wanted a card, all I needed to do was import {Card} from "@material/ui"
and I had a Card
component with an API that had decent documentation to work with. As I continued to build UI in this fashion, I noticed I could quickly impress clients and output work quickly, but the law of diminishing returns was rapidly approaching. Lots of friction was coming up with my own mental model of CSS, and even more so when team members had to collaborate. The more I focused on velocity, the farther I was getting away from adaptability.
What I mean by adaptability is that when I wanted to customize a given part of UI exposed by a library, or really dive deeper into designing something, I actually had very little domain knowledge of what was going on. I felt too far abstracted from the building blocks. The developer experience of overriding material UI classes with CSS that I didn't fully understand the inner workings of felt like I was throwing possible solutions at a wall praying they would stick.
This wasn't a scalable way to write UI. I was about to drive a van cross-country on low tires and a weak transmission. This feeling nudged me to reshape my entire mental model of front end - It was time for a paradigm shift. I decided to take Josh Comeau's CSS course, and after reading Maxime Heckel's blog post Building a Design System From Scratch, I was committed to building my own system that would lead to higher performing UI and an optimize for developer experience. I was officially on a new and exciting journey.
Layout Components
The first thing we'll set out to do is create a mechanism that will allow us to control responsive layout on a page in a flexible manner. To optimize for modularity, these layout components will need to enable different behavior. For example, for creating a marketing site or content-oriented page, copy should be centered and have a constrained max width. However, we also want to be able to opt out out this constraint in order to use the entire width of the viewport (If we are building a dashboard, or more interface oriented UI).
To do this, we create a core Layout Wrapper. You may notice Stitches being used as a CSS-in-JS solution (I will cover this a bit later)
import { styled } from 'stitches.config';
export const LayoutWrapper = styled('main', {
display: 'grid',
gridTemplateColumns: '1fr min(1200px, calc(100% - 64px)) 1fr',
gridColumnGap: '32px',
minHeight: '100%',
'> *': {
gridColumn: 2,
},
});
What this wrapper component allows us to do is create a baseline three-column grid with all content injected in the middle. We constrain the middle column to a 1200px max width, and this enables a nice responsive behavior: if the viewport is ever lower than 1200px, our horizontal gutters aren't compromised.
Next, we want another type of wrapper component that will be a child of the core layout. This wrapper component will allow us to inject children into a container and opt into fixing a footer at the bottom of a page. Following our core principle of modularity, we create a <ContentWrapper>
component that does a specific and isolated job.
export const ContentWrapper = styled("div", {
display: "flex",
flexDirection: "column",
minHeight: "100%",
});
We need to give <ContentWrapper>
a min-height of 100% because unlike width, block level elements seek to take up the least amount of height possible. But this isn't enough - height declarations in CSS look at their parent to figure out what to wrap around! This introduces a small problem. The footer is floating and can't be fixed to the bottom, but <ContentWrapper>
thinks it is doing it's job perfectly. To solve this problem, we need to provide a height argument for the parent of <ContentWrapper>
so that it can source its height context appropriately. We create a global CSS reset and add the following code to target the DOM's body node. (among some other things inspired by Josh' Comeau's CSS reset):
/* ensures that our layout wrapper can consume min-height of 100% */
html,
body,
#__next {
height: 100%;
}
We can then create a footer and give it a margin-top: auto
css property in order to fix it to the bottom of the page. Our Footer is taking shape! To fully realize the <Footer>
however, we still have one thing left to do. As I described above, we need a way for children of the core <LayoutWrapper>
to opt out of the LayoutWrapper's width constraint. In its current state, if we give the Footer a different background color than the document body, the <Footer>
will be capped at a 1200px width, and on mobile, have these horizontal gutters on the sides rendering a different color. I really liked Josh Comeau's approach for this, and wanted to apply his solution to our variant-driven architecture exposed by the Stitches paradigm (more about that soon).
To do this, we create a <FullBleedWrapper>
that can be wrapped around children within the <LayoutWrapper>
component. The FullBleedWrapper
needs to work both as direct child of <LayoutWrapper>
, and as a nested child of the <ContentWrapper>
. It's important to make this distinction, because our <ContentWrapper>
has a display: flex
property. Meaning, children of ContentWrapper are inheriting a different CSS layout algorithm! We have left the Flow layout algorithm, and are now in Flex because of inheritance.
We account for this by introducing the boolean variant.
export const FullBleedWrapper = styled('main', {
width: '100%',
gridColumn: '1 / 4',
variants: {
insideContentWrapper: {
true: {
width: '100vw',
marginLeft: '-50vw',
left: '50%',
position: 'relative',
},
},
},
});
With these three Layout components, we are ready to create a reusable Page Layout.
type LayoutProps = {
children?: React.ReactNode;
};
export const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<>
<LayoutWrapper>
<ContentWrapper>
<Header />
{children}
<FullBleedWrapper insideContentWrapper>
<Footer />
</FullBleedWrapper>
</ContentWrapper>
</LayoutWrapper>
</>
);
};
The boolean variant exposed by the Stitches API works just like a normal react prop! Consequently, it unlocks a new dimension to leveraging modularity in our design system, and brings us to the next dish in what just became a 5 course meal.
Variant Driven Design Patterns
After watching Pedro Duarte’s demo The Future of Front End, I was so excited about the way the Stitches API enables variant driven components. I found it fit so well with React’s declarative paradigm. We can have a single button component with different variants that handle all use cases our front end will ever need. Let’s look at our Button.
export const Button = styled('button', {
border: 'none',
fontSize: '$md',
backgroundColor: '$primary',
color: 'white',
borderRadius: '$radius8',
boxShadow: '$shadowElevMd',
cursor: 'pointer',
height: '48px',
padding: '$1.5',
'&:active': {
boxShadow: '$shadowElevSm',
transform: 'translateY(4px)',
},
variants: {
variant: {
primary: {
backgroundColor: '$primary',
'&:hover': {
backgroundColor: '$secondary',
},
},
secondary: {
backgroundColor: '$secondary',
'&:hover': {
backgroundColor: '$primary',
},
},
},
outlined: {
true: {
borderColor: '$grey700',
border: '5px solid',
},
},
},
compoundVariants: [
{
variant: 'dark',
outlined: true,
css: {
// paddingX: '50px',
},
},
],
defaultVariants: {
variant: 'light',
},
});
Ok this is getting exciting. Our Button handles primary
and secondary
variants that declaratively change the Button's appearance. I also added a boolean variant to control whether the Button is outlined. Here's an example of how to render this Button
with a primary variant, outlined display and text that says "Submit" within a React component.
<Button variant="primary" outlined> Submit </Button>
I also added a compound variant to show additional ways we can get creative with the button. The compound variant says:
if (variant === "dark" && outlined === true) {
// add the following css
}
It's so neat how we can think in programming logic within our Stitches styled component - it's a bit different than the way Styled components work where we can consume props with functions - a bit of a mental shift - but same sort of principle. Lastly, you may notice we started referencing variables instead of values in our CSS with a strange $ syntax. Before I dive into theme tokens and CSS variables, let's take this power of modularity one step further.
Typography
Typography is so involved. The more I started researching designer's approaches, the more I realized design systems as a whole, and Typography especially, were pretty subjective. There was no right answer - and maybe - this was an opportunity to create. After watching Mizko's Figma tutorials, I learned about this cool type-scale website and started playing around with different elements of Typography. Typography can be broken down into the realms of size, weight, family, color and html tag. Using these building blocks, we can create a base Text
component that applies the variant-driven behavior we saw in the <Button>
component to text. Let's take a look.
export const Text = styled("span", {
variants: {
weight: {
light: {
fontWeight: "$light",
},
normal: {
fontWeight: "$normal",
},
medium: {
fontWeight: "$medium",
},
semiBold: {
fontWeight: "$semiBold",
},
bold: {
fontWeight: "$bold",
},
},
size: {
xs: { fontSize: "$xs" },
sm: { fontSize: "$sm" },
md: { fontSize: "$md" },
lg: { fontSize: "$lg" },
xl: { fontSize: "$xl" },
["2xl"]: { fontSize: "$2xl" },
["3xl"]: { fontSize: "3xl" },
},
variant: {
primary: {
color: "var(--colors-text-primary)",
},
secondary: {
color: "var(--colors-text-secondary)",
},
},
},
defaultVariants: {
variant: "primary",
weight: "medium",
},
});
This baseline Text component allows us to create reusable components for any Text. For example, here is a Heading
component.
import { PropsWithChildren } from "react";
import { TypographyTypes } from "./TypographyTypes";
import { Text } from "./Text";
export const Heading = (props: PropsWithChildren<TypographyTypes>) => {
return (
<Text as={"h1"} size={"xl"} weight={"bold"} {...props}>
{props.children}
</Text>
);
};
But there's something I noticed - it'd be really nice if we allowed some Typography to scale with screen size! If we have a Header on a page with a 24px font size on mobile, it'd be great to scale to a larger size on desktop. Smaller text like Body
however, with a 16px font size, would be fine at that size across devices. To solve this problem, we add an empty boolean variant to our Text component called scale
. We then leverage the compound variant and write breakpoints within the css declaration that change font size dynamically. Our solution can then look something like this:
compoundVariants: [
{
size: "xl",
scale: true,
css: {
'@bp2': {
fontSize: "$2xl",
}
},
}
]
I don't love this code though. Writing the breakpoint within the css object exposed by the compound variant is not very friendly and requires nesting an object within an object within an object. Furthermore, to add support for scaling Typography to many screen sizes, it would require writing individual breakpoints for each screen size for every font size's compound variant. Yuck! To solve this problem, we leverage the compound variant to reference an alternative set of design tokens, and write the media queries within the design token files instead of the Text component. Here is our final Text component.
export const Text = styled("span", {
variants: {
weight: {
light: {
fontWeight: "$light",
},
normal: {
fontWeight: "$normal",
},
medium: {
fontWeight: "$medium",
},
semiBold: {
fontWeight: "$semiBold",
},
bold: {
fontWeight: "$bold",
},
},
size: {
xs: { fontSize: "$xs" },
sm: { fontSize: "$sm" },
md: { fontSize: "$md" },
lg: { fontSize: "$lg" },
xl: { fontSize: "$xl" },
["2xl"]: { fontSize: "$2xl" },
["3xl"]: { fontSize: "3xl" },
},
variant: {
primary: {
color: "var(--colors-text-primary)",
},
secondary: {
color: "var(--colors-text-secondary)",
},
},
scale: {
true: {},
},
},
compoundVariants: [
{
size: "sm",
scale: true,
css: {
fontSize: "$scaledSm",
},
},
{
size: "md",
scale: true,
css: {
fontSize: "$scaledMd",
},
},
{
size: "lg",
scale: true,
css: {
fontSize: "$scaledLg",
},
},
{
size: "xl",
scale: true,
css: {
fontSize: "$scaledXl",
},
},
{
size: "2xl",
scale: true,
css: {
fontSize: "$scaled2Xl",
},
},
{
size: "3xl",
scale: true,
css: {
fontSize: "$scaled3Xl",
},
},
],
defaultVariants: {
variant: "primary",
weight: "medium",
},
});
With about 100 lines of code, we can not only generate all possible instances of Typography, but can also opt in to scale Typography across a dynamic amount of screen sizes. To see this working on production, check out the Amarillo site and resize your viewport. Notice how the Heading's font size changes. Let's take a moment to bask in this moment of Front End bliss together. Let's also take a brief break and enjoy Fred Again...'s amazing Tiny Desk concert.
Design tokens via CSS Variables
Ok, it's time to talk about the $
elephant in the room. After reading Maxime's article The Power of Composition with CSS Variables and learning how to think programmatically about color via his HSL-based color API, I experienced yet another paradigm shift. Maxime and Josh were referencing variables at the component level, not values. Mind blown, mind totally blown. When we abstract behavior in this way, we stop thinking "this property points at this value", and start thinking "this property points at this variable". Remember how we abstracted the media queries for scaled fonts in <Text>
away from the actual component? Instead of imperatively telling the component how to behave, we told it to observe a reactive variable. We can follow this path, and infer that if we expose css variables globally, we'll have a global context to consume design tokens from. To do this, we leverage the globalCss
function exposed by the Stitches library to append theme tokens to the :root
node of the DOM. I learned about this by studying the way Maxime did this.
export const globalStyles = globalCss({
":root": {
...lightTheme,
...darkTheme,
...colors,
...fontSizes,
...palette,
...layout,
...spaces,
...shadows,
...radii,
...responsiveFonts,
},
});
globalStyles();
And finally, here is our typography.ts
design tokens file, where we can see this css variable magic in action.
export const fontSizes = {
'--font-size-12': '12px',
'--font-size-14': '0.875rem',
'--font-size-16': '1rem',
'--font-size-18': '1.125rem',
'--font-size-20': '1.25rem',
'--font-size-22': '1.375rem',
'--font-size-24': '1.5rem',
'--font-size-26': '1.625rem',
'--font-size-28': '1.75rem',
'--font-size-30': '1.875rem',
'--font-size-32': '2rem',
'--font-size-34': '2.125rem',
'--font-size-36': '2.25rem',
'--font-size-38': '2.375rem',
'--font-size-40': '2.5rem',
};
export const responsiveFonts = {
'--ds-fonts-xs': 'var(--font-size-12)',
'--ds-fonts-sm': 'var(--font-size-16)',
'--ds-fonts-md': 'var(--font-size-20)',
'--ds-fonts-lg': 'var(--font-size-28)',
'--ds-fonts-xl': 'var(--font-size-32)',
'@media (min-width: 768px)': {
'--ds-fonts-xs': 'var(--font-size-14)',
'--ds-fonts-sm': 'var(--font-size-18)',
'--ds-fonts-md': 'var(--font-size-24)',
'--ds-fonts-lg': 'var(--font-size-34)',
'--ds-fonts-xl': 'var(--font-size-40)',
},
};
We can continue iterating on this fundamental idea of modularity and expand our mental model of css variables to support multiple UI themes. As we can see with the globalCSS
function, we are de-structuring several JS objects in order to append their contents to the root node of the DOM. It's worth observing, that in the responsiveFonts
object, our key value pairs are not limited to a single level - we can nest objects inside of the values. The magic of JavaScript. We can apply this logic to generate themes. To do this, we create key value pairs that reference entire CSS classes, and de-structure those objects in the same manner. Let's take a look at the lightTheme
and darkTheme
.
export const darkTheme = {
".dark-theme": {
"--colors-text-primary": "var(--grey-300)",
"--colors-text-primary-accent": "var(--purple-300)",
"--colors-text-secondary": "var(--grey-800)",
"--colors-text-secondary-accent": "var(--grey-400)",
"--colors-primary": "var(--blue-300)",
"--colors-secondary": "var(--purple-400)",
"--colors-background": "var(--grey-900)",
"--colors-card-background": "var(--grey-100)",
},
};
export const lightTheme = {
".light-theme": {
"--colors-text-primary": "var(--grey-900)",
"--colors-text-primary-accent": "var(--purple-400)",
"--colors-text-secondary": "var(--grey-300)",
"--colors-text-secondary-accent": "var(--grey-600)",
"--colors-primary": "var(--blue-500)",
"--colors-secondary": "var(--purple-300)",
"--colors-background": "var(--grey-100)",
"--colors-card-background": "var(--grey-900)",
},
};
I like to refer to these as semantic tokens. We need to be strategic here. Since the value of var(--colors-text-primary)
needs to change based on theme, we declare multiple instances of this property. We can then toggle the class by conditionally applying dark-theme
and light-theme
class names to the document body. We can do this in code by leveraging react context to provide state, and a useTheme
hook to consume it. Ideally, we'd like to use local storage to save preferences, and also observe a user's operating system preference to deduce which theme to use. It's actually a pretty involved problem which this article talks about. We can view our early prototype of this on production on the Amarillo site by toggling the theme button on the top right.
Its about what we are building
We've been in the technical weeds for a good bit of this post, and so I want to zoom out. I know there's been a lot of Stitches used here - but it's important to think beyond it. I've come to understand that the true value of our design system is in our mental model - not code.
The principles of modularity and variant driven architecture can be applied to any coding solution. Given the uncertainty of CSS-in-JS , it's important to frame our mental model of design systems as a high level dialogue between velocity and adaptability.
I mentioned in the Typography section how design systems are subjective. If we follow that thought, we can say that design systems should support the stage of design we are in. As I was diving in more and more to the technical aspects of theme tokens, figuring out what to name things and pondering future issues of scalability, I was not only losing speed, but was also loosing track of what I was building. While it's important to develop the how, it's imperative to not lose track of the what. A good measure of a successful design system is if we have an intuitive process where we can iterate components in code in harmony with Figma.
We can observe velocity and adaptability being linked to optimization as a trade-off triangle. We need to think on a high level and modular fashion in order to set up a system to scale, but also execute at the same time.
This perhaps, is the greatest challenge.
How can we build resiliency in a design system for whatever the rapidly evolving front end future holds? With a solid mental model, we are off to a good start. And to think we are just getting started - is perhaps- the most exciting part.
Special thanks to Maxime and Josh for your contributions to the community. If there is anything here that isn't properly credited, please let me know.