Introduction
This is part four of our series on building a complete design system from scratch. In the previous tutorial we created our Box
& Flex
component. In this tutorial we will create our first theme able component Badge with light and dark modes. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.
Step One: Badge styles & theming
We want to achieve the following for the Badge
component -
<Badge colorScheme="blue" variant="outline" size="md">
Sample Badge
</Badge>
We have the colorScheme
prop, taking values - red, orange, blue, purple, etc. We can pass 3 variants to the component - outline, solid, subtle. Basically, we have the colorScheme
& variant
combination. We also have 3 size variants - sm, md, lg.
For handling the light and dark modes we will use the data attribute selector [data-theme="dark"]
, that means the root of our project should have the data-theme="light"
or data-theme="dark"
attribute. We will not use css variables for theming because chakra ui uses css in js and therefore it has a flat theme object, for the dark mode chakra ui is using javascript to create dark mode colors on the fly using rgba(), we will use scss rgba()
function to achieve this.
The consumer of our library will use the attribute data-theme="light"
or data-theme="dark"
on its root element, we won't be creating any ThemeProvider
because we don't need.
Under components/atoms
create a new folder badge
and under badge
folder create a new file badge.scss
-
@use "sass:map";
/* base badge styles */
.badge {
display: inline-flex;
vertical-align: top;
align-items: center;
max-width: 100%;
outline: 0;
border-radius: 0.125rem;
font-weight: $font-weight-semibold;
line-height: $line-height-shorter;
/* badge size sm */
&.sm {
min-height: $spacing-md;
min-width: $spacing-md;
font-size: $font-size-xs;
padding-left: $spacing-xxs;
padding-right: $spacing-xxs;
}
/* badge size md */
&.md {
min-height: $spacing-lg;
min-width: $spacing-lg;
font-size: $font-size-sm;
padding-left: $spacing-xxs;
padding-right: $spacing-xxs;
}
/* badge size lg */
&.lg {
min-height: $spacing-xl;
min-width: $spacing-xl;
font-size: $font-size-md;
padding-left: $spacing-xs;
padding-right: $spacing-xs;
}
@each $color in $color-schemes {
$color-100: map.get($colors-map, #{$color + '100'});
$color-200: map.get($colors-map, #{$color + '200'});
$color-500: map.get($colors-map, #{$color + '500'});
$color-800: map.get($colors-map, #{$color + '800'});
/* badge variant solid with colorscheme & dark mode */
&.solid.#{"" + $color} {
background-color: $color-500;
color: map.get($colors-map, "white");
[data-theme="dark"] & {
color: map.get($colors-map, 'whiteAlpha800');
background-color: rgba($color-500, 0.6);
}
}
/* badge variant outline with colorScheme & dark mode */
&.outline.#{"" + $color} {
color: $color-500;
box-shadow: inset 0 0 0px 1px currentColor;
[data-theme="dark"] & {
color: rgba($color-200, 0.8);
}
}
/* badge variant subtle with colorScheme & dark mode */
&.subtle.#{"" + $color} {
color: $color-800;
background-color: $color-100;
[data-theme="dark"] & {
color: $color-200;
background-color: rgba($color-200, 0.16);
}
}
}
}
For the css classes we have to -
- First we created the base
.badge
class. - Then we created the size variants classes
sm, md, lg
scoping all of them under the badge class. - We then create variant classes combined with the
colorScheme
-.solid .red, .outline .red, .subtle .red
, we used scss map function to create all these combinations. - We are mapping over the $color-schemes array and for each
colorScheme
we are picking the respective values from $colors-map our color pallet. Both $color-schemes & $colors-map are declared undercss/varaibles/_colors.scss
. - For the variant classes we also need to handle
[data-theme="dark"]
condition in the css say if you passcolorScheme
asred
andvariant
assolid
how will it look in dark mode. - So for the variants we are creating 2 classes, one for the light mode eg -
.red .outline
and one for the dark mode eg -[data-theme="dark"] .red .outline
.
Notice one thing, we have not imported $color-schemes, $colors-map from our variables folder, instead of importing our variables in every .scss file we will add the following configuration to vite's defineConfig
function -
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "./src/css/variables/_fonts.scss" as *;
@use "./src/css/variables/_spacings.scss" as *;
@use "./src/css/variables/_colors.scss" as *;
`,
},
},
},
It will add these variables to all scss
files that are imported in .tsx
files, like we imported our badge.scss
file in the badge/index.tsx
file.
Step Two: Badge component
Under atoms/badge
create a new file index.tsx
-
import React from "react";
import { cva, VariantProps } from "class-variance-authority";
import { ColorScheme } from "../../../cva-utils";
import { Box, BoxProps } from "../layouts";
import "./badge.scss";
const badge = cva(["badge"], {
variants: {
variant: {
outline: "outline",
solid: "solid",
subtle: "subtle",
},
size: {
sm: "sm",
md: "md",
lg: "lg",
},
},
defaultVariants: {
variant: "subtle",
size: "md",
},
});
export type BadgeProps = VariantProps<typeof badge> &
BoxProps & {
colorScheme?: ColorScheme;
};
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
(props, ref) => {
const {
variant,
size,
colorScheme = "green",
children,
className,
...delegated
} = props;
const badgeClasses = badge({
variant,
size,
className: [colorScheme, className].join(" "),
});
return (
<Box ref={ref} className={badgeClasses} {...delegated}>
{children}
</Box>
);
}
);
The above code is pretty straightforward, we created the cva function along with the variants, then we created the badgeClasses
. Finally, we pass the badgeClasses
to the className prop.
Step Three: Badge story
Under atoms/badge
create a new file badge.stories.tsx
-
import * as React from "react";
import { StoryObj } from "@storybook/react";
import { Badge, BadgeProps } from ".";
import { colorSchemes } from "../../../cva-utils";
export default {
title: "Atoms/Badge",
};
export const Playground: StoryObj<BadgeProps> = {
args: {
variant: "subtle",
colorScheme: "green",
size: "md",
},
argTypes: {
variant: {
name: "variant",
type: { name: "string", required: false },
options: ["outline", "solid", "subtle"],
description: "Variant for the Badge",
table: {
type: { summary: "string" },
defaultValue: { summary: "subtle" },
},
control: {
type: "select",
},
},
colorScheme: {
name: "colorScheme",
type: { name: "string", required: false },
options: colorSchemes,
description: "The Color Scheme for the button",
table: {
type: { summary: "string" },
defaultValue: { summary: "green" },
},
control: {
type: "select",
},
},
size: {
name: "size (s)",
type: { name: "string", required: false },
options: ["sm", "md", "lg"],
description: "Tag height width and horizontal padding",
table: {
type: { summary: "string" },
defaultValue: { summary: "md" },
},
control: {
type: "select",
},
},
},
render: (args) => <Badge {...args}>SAMPLE BADGE</Badge>,
};
From the terminal run yarn storybook
and check the badge stories.
Step Four: Create a theme addon in Storybook
Badge
component works both for light and dark mode, but we don't have a way in storybook to switch themes. Well we have to build one. I would recommend, you read this awesome article on how to create a theme switcher in storybook.
Under .storybook/preview.tsx
file paste the following -
import * as React from "react";
import { Flex, FlexProps } from "../src/components/atoms/layouts";
import "../src/css/main.scss";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
function Container(props: FlexProps) {
const { style, children, ...delegated } = props;
return (
<Flex
align="start"
p="md"
style={{ minHeight: "100vh", ...style }}
{...delegated}
>
{children}
</Flex>
);
}
const StoriesWithTheme = (StoryFun, context) => {
const theme = context.parameters.theme || context.globals.theme;
if (theme === "split") {
return (
<Flex>
<Container bg="white" style={{ flexBasis: "50%" }} data-theme="light">
<StoryFun />
</Container>
<Container bg="gray800" style={{ flexBasis: "50%" }} data-theme="dark">
<StoryFun />
</Container>
</Flex>
);
}
return (
<Container bg={theme === "dark" ? "gray800" : "white"} data-theme={theme}>
<StoryFun />
</Container>
);
};
export const globalTypes = {
theme: {
name: "Change Theme",
description: "Global theme for components",
defaultValue: "light",
toolbar: {
// The icon for the toolbar item
icon: "circlehollow",
// Array of options
items: [
{ value: "light", icon: "circlehollow", title: "light-view" },
{ value: "dark", icon: "circle", title: "dark-view" },
{ value: "split", icon: "graphline", title: "split-view" },
],
// Property that specifies if the name of the item will be displayed
showName: true,
},
},
};
/**
* This decorator is a global decorator will
* be applied to each and every story
*/
export const decorators = [StoriesWithTheme];
Now from the terminal run yarn storybook
and play with the Theme switcher the above code will be understandable. To change the theme as stated earlier we will use the data attribute data-theme
on our root element, we would change the theme in a similar way if are using our library in our React projects.
Why are we not using a ThemeProvider
? Because we don't need a ThemeProvider
, the consumer of our library has to change the data-theme
attribute on the root element of his project and handle theme in whichever way he wants store it in Redux, zustand, or Context.
The consumer of our app can also create a ThemeContext
. But never wrap your whole app inside a Context because if something updates the whole app will re-render. If say you want to hide some components in dark mode, you just wrap those components inside the context rather than wrapping the whole app.
Conclusion
In this tutorial we created the first themeable component Badge
. All the code for this tutorial can be found here. In the next tutorial we will create a spinner and some Icons. Until next time PEACE.
Top comments (0)