Introduction
This is part seven of our series on building a complete design system from scratch. In the previous tutorial created Alert
component. In this tutorial we will create a theme able Button
component with dark mode. I would encourage you to play around with the deployed storybook. All the code for this series is available on GitHub.
Step One: Button styles & theming
We want to achieve the following for the Button
component -
/* Normal Button usage */
<Button size="md" colorScheme="red" variant="solid">
Submit
</Button>
/* Button in a loading state, showing a spinner & disabled */
<Button isLoading size="md" colorScheme="red" variant="solid">
Submit
</Button>
/* Button in a loading state, showing a spinner & text */
<Button
isLoading
size="md"
loadingText="Submit...."
colorScheme="red"
variant="outline"
spinnerPlacement="start"
>
Submit
</Button>
/* Button with icon; can be left and right */
<Button
size="md"
leftIcon={<EmailIcon />}
colorScheme="green"
variant="solid"
>
Email
</Button>
- The
Button
component has a lot of different variants. - We have the normal
colorScheme
andvariant
combination, along with the size variant. - Then we can also show a loading spinner and pass a loadingText and we can even place the spinner either to the right or left of the loadingText.
- We can also pass a leftIcon or rightIcon prop which will display the icon either to the right or left of the button label.
We will first create 2 components ButtonIcon
and ButtonSpinner
to handle the spinner and icon states.
Step 2: ButtonIcon & ButtonSpinner components
Under atoms
create a folder forms
. Inside forms
create a new folder button
, now under atoms/forms/button
create button.scss
file and paste the following -
.button-spinner {
display: flex;
font-size: 1em;
line-height: normal;
align-items: center;
position: relative;
color: currentColor;
margin: 0px;
&.start {
margin-right: 0.5rem;
}
&.end {
margin-left: 0.5rem;
}
&.isAbsolute {
position: absolute;
}
}
Now create a new file button-spinner.tsx
and paste the following -
import * as React from "react";
import { cva, VariantProps } from "class-variance-authority";
import { Spinner } from "../../feedback";
import "./button.scss";
const buttonSpinner = cva(["button-spinner"], {
variants: {
placement: {
start: "start",
end: "end",
},
isAbsolute: {
true: "isAbsolute",
},
},
});
export type ButtonSpinnerProps = VariantProps<typeof buttonSpinner> & {
children?: React.ReactElement;
labelText?: string;
};
export function ButtonSpinner(props: ButtonSpinnerProps) {
const { labelText, placement, children = <Spinner /> } = props;
return (
<div
className={buttonSpinner({
placement: labelText ? placement : undefined,
isAbsolute: !labelText,
})}
>
{children}
</div>
);
}
Create another new file button-icon.tsx
and paste -
import * as React from "react";
import { MarginVariants, margin } from "../../../../cva-utils";
export type ButtonIconProps = MarginVariants & {
children?: React.ReactNode;
};
export function ButtonIcon(props: ButtonIconProps) {
const { children, m, mt, mr, mb, ml, ...delegated } = props;
const componentChildren = React.isValidElement(children)
? React.cloneElement(children as any, {
"aria-hidden": true,
focusable: false,
})
: children;
return (
<span className={margin({ m, mt, mr, mb, ml })} {...delegated}>
{componentChildren}
</span>
);
}
Take a note we are using the helper cva function margin, we will pass the iconSpacing
prop to the Button
component that will add the margin to the icons.
Step Three: Button Component
Under atoms/forms/button
create the button.tsx
file -
import React from "react";
import { cva, cx, VariantProps } from "class-variance-authority";
import { margin, MarginVariants, ColorScheme } from "../../../../cva-utils";
import { ButtonSpinner } from "./button-spinner";
import { ButtonIcon } from "./button-icon";
import "./button.scss";
type ButtonContentProps = Pick<
ButtonProps,
"leftIcon" | "rightIcon" | "children" | "iconSpacing"
>;
function ButtonContent(props: ButtonContentProps) {
const { leftIcon, rightIcon, children, iconSpacing } = props;
return (
<React.Fragment>
{leftIcon && <ButtonIcon mr={iconSpacing}>{leftIcon}</ButtonIcon>}
{children}
{rightIcon && <ButtonIcon ml={iconSpacing}>{rightIcon}</ButtonIcon>}
</React.Fragment>
);
}
const button = cva(["button"], {
variants: {
variant: {
link: "link",
outline: "outline",
solid: "solid",
ghost: "ghost",
unstyled: "unstyled",
},
size: {
xs: "xs",
sm: "sm",
md: "md",
lg: "lg",
},
isFullWidth: {
true: "isFullWidth",
},
},
defaultVariants: {
variant: "solid",
size: "md",
isFullWidth: false,
},
});
export type ButtonProps = MarginVariants &
VariantProps<typeof button> &
React.ComponentPropsWithoutRef<"button"> & {
colorScheme?: ColorScheme;
isLoading?: boolean;
isDisabled?: boolean;
loadingText?: string;
leftIcon?: React.ReactElement;
rightIcon?: React.ReactElement;
iconSpacing?: MarginVariants["m"];
spinner?: React.ReactElement;
spinnerPlacement?: "start" | "end";
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
m,
mt,
mr,
mb,
ml,
variant,
size,
isFullWidth,
colorScheme = "teal",
className,
isDisabled = false,
isLoading = false,
loadingText,
spinnerPlacement = "start",
spinner,
rightIcon,
leftIcon,
iconSpacing = "xxs",
children,
...delegated
} = props;
const buttonClasses = cx(
margin({ m, mt, mr, mb, ml }),
button({
variant,
size,
isFullWidth,
className: [colorScheme, className].join(" "),
})
);
const buttonContentProps = {
rightIcon,
leftIcon,
iconSpacing,
children,
};
return (
<button
ref={ref}
className={buttonClasses}
disabled={isDisabled || isLoading}
{...delegated}
>
{isLoading && spinnerPlacement == "start" && (
<ButtonSpinner labelText={loadingText} placement="start">
{spinner}
</ButtonSpinner>
)}
{isLoading ? (
loadingText || (
<span style={{ opacity: 0 }}>
<ButtonContent {...buttonContentProps} />
</span>
)
) : (
<ButtonContent {...buttonContentProps} />
)}
{isLoading && spinnerPlacement === "end" && (
<ButtonSpinner labelText={loadingText} placement="end">
{spinner}
</ButtonSpinner>
)}
</button>
);
}
);
I would suggest you play with the Button component on the deploy preview, you will understand the code automatically. We created the button cva function with all the variants, handled the cases for loading, loading with loadingText, icons.
Step Four: Button styles with dark mode
The styles for the Button
are complicated meaning, we are using a combination of css custom properties and plain scss variables. I would recommend you take a look at chakra ui source code. For some colorScheme
and variants
we need to use the rgba()
function especially for dark mode styles. Similar to the Alert
, Badge
components we need to create classes for the colorScheme
& varaint
combination and also target the dark mode. Under button.scss
paste the following code -
@use "sass:map";
/* base button styles */
.button {
border: none;
outline: none;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25em 0.75em;
font-weight: $font-weight-semibold;
text-align: center;
line-height: 1.1;
transition: 220ms all ease-in-out;
border-radius: 0.375rem;
&:focus {
box-shadow: outline;
}
&:hover, &:hover:disabled {
background: initial;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
box-shadow: none;
}
/* isFullWidth prop */
&.isFullWidth {
width: 100%;
}
/* button size xs */
&.xs {
height: 1.5rem;
min-width: 1.5rem;
font-size: $font-size-xs;
padding-left: $spacing-xs;
padding-right: $spacing-xs;
}
/* button size sm */
&.sm {
height: 2rem;
min-width: 2rem;
font-size: $font-size-sm;
padding-left: $spacing-sm;
padding-right: $spacing-sm;
}
/* button size md */
&.md {
height: 2.5rem;
min-width: 2.5rem;
font-size: $font-size-md;
padding-left: $spacing-md;
padding-right: $spacing-md;
}
/* button size lg */
&.lg {
height: 3rem;
min-width: 3rem;
font-size: $font-size-lg;
padding-left: $spacing-lg;
padding-right: $spacing-lg;
}
}
/* button variant link */
.button.link {
--color: none;
--active-color: none;
background: none;
line-height: normal;
vertical-align: baseline;
color: var(--color);
&:hover {
text-decoration: underline;
}
&:hover:disabled {
text-decoration: none;
}
&:active {
color: var(--active-color);
}
@each $color in $color-schemes {
$color-200: map.get($colors-map, #{$color + '200'});
$color-500: map.get($colors-map, #{$color + '500'});
$color-700: map.get($colors-map, #{$color + '700'});
&.link.#{"" + $color} {
--color: #{$color-500};
--active-color: #{$color-700};
[data-theme="dark"] & {
--color: #{$color-200};
--active-color: #{$color-500};
}
}
}
}
/* button variant unstyled */
.button.unstyled {
background: none;
color: inherit;
display: inline;
line-height: inherit;
margin: 0;
[data-theme="dark"] & {
color: #{map.get($colors-map, "white")};
}
}
/* button variant solid */
.button.solid {
--bg: none;
--color: var(--color-black);
--bg-hover: none;
--bg-active: none;
background: var(--bg);
color: var(--color);
&:hover {
background: var(--bg-hover);
}
&:hover:disabled {
background: var(--bg);
}
&:active {
background: var(--bg-active);
}
}
@each $color in $color-schemes {
@if ($color == gray) {
.button.solid.gray {
--bg : #{map.get($colors-map, "gray100")};
--bg-hover: #{map.get($colors-map, "gray200")};
--color: #{map.get($colors-map, "black")};
--bg-active: #{map.get($colors-map, "gray300")};
}
} @else if ($color == yellow or $color == cyan) {
.button.solid.#{"" + $color} {
--bg : #{map.get($colors-map, #{$color + '400'})};
--bg-hover: #{map.get($colors-map, #{$color + '500'})};
--color: #{map.get($colors-map, "black")};
--bg-active: #{map.get($colors-map, #{$color + '600'})};
}
} @else {
.button.solid.#{"" + $color} {
--bg : #{map.get($colors-map, #{$color + '500'})};
--color: #{map.get($colors-map, "white")};
--bg-hover: #{map.get($colors-map, #{$color + '600'})};
--bg-active: #{map.get($colors-map, #{$color + '700'})};
}
}
}
@each $color in $color-schemes {
@if ($color == gray) {
[data-theme="dark"] .button.solid.gray {
--bg : #{map.get($colors-map, "whiteAlpha200")};
--color: #{map.get($colors-map, "whiteAlpha900")};
--bg-hover: #{map.get($colors-map, "whiteAlpha300")};
--bg-active: #{map.get($colors-map, "whiteAlpha400")};
}
} @else {
[data-theme="dark"] .button.solid.#{"" + $color} {
--bg : #{map.get($colors-map, #{$color + '200'})};
--color: #{map.get($colors-map, "gray800")};
--bg-hover: #{map.get($colors-map, #{$color + '300'})};
--bg-active: #{map.get($colors-map, #{$color + '400'})};
}
}
}
/* button variant ghost */
.button.ghost {
--color: none;
--bg-hover: none;
--bg-active: none;
color: var(--color);
background: transparent;
&:hover {
background: var(--bg-hover);
}
&:active {
background: var(--bg-active);
}
}
.button.outline {
--color: none;
--bg-hover: none;
--bg-active: none;
color: var(--color);
background: transparent;
border: 1px solid currentColor;
&:hover {
background: var(--bg-hover);
}
&:active {
background: var(--bg-active);
}
}
@each $color in $color-schemes {
@if ($color == gray) {
.button.ghost.gray, .button.outline.gray {
--color: inherit;
--bg-hover: #{map.get($colors-map, "gray100")};
--bg-active: #{map.get($colors-map, "gray200")};
}
} @else {
.button.ghost.#{"" + $color}, .button.outline.#{"" + $color} {
--color: #{map.get($colors-map, #{$color + '600'})};
--bg-hover: #{map.get($colors-map, #{$color + '50'})};
--bg-active: #{map.get($colors-map, #{$color + '100'})};
}
}
}
@each $color in $color-schemes {
@if ($color == "gray") {
[data-theme="dark"] .button.ghost.gray,
[data-theme="dark"] .button.outline.gray {
--color: #{map.get($colors-map, "whiteAlpha900")};
--bg-hover: #{map.get($colors-map, "whiteAlpha200")};
--bg-active: #{map.get($colors-map, "whiteAlpha300")};
}
} @else {
[data-theme="dark"] .button.ghost.#{"" + $color},
[data-theme="dark"] .button.outline.#{"" + $color} {
$fg-color: map.get($colors-map, #{$color + '200'});
--color: #{$fg-color};
--bg-hover: #{rgba($fg-color, 0.12)};
--bg-active: #{rgba($fg-color, 0.12)};
}
}
}
In scss
to assign a variable to a css custom property you need to use interpolation #{} like so --bg-active: #{rgba($fg-color, 0.12)}
; Please feel free to ask any questions.
Step Five: Button Story
Under atoms/forms/button
create the button.stories.tsx
file and paste the following -
import * as React from "react";
import { StoryObj } from "@storybook/react";
import { colorSchemes, spacingControls } from "../../../../cva-utils";
import { Flex } from "../../layouts";
import { ArrowForwardIcon, EmailIcon } from "../../icons";
import { Button, ButtonProps } from "./button";
const { spacingOptions, spacingLabels } = spacingControls();
export default {
title: "Atoms/Forms/Button",
};
export const Playground: StoryObj<ButtonProps> = {
parameters: {
theme: "split",
},
args: {
variant: "solid",
colorScheme: "green",
size: "md",
isFullWidth: false,
m: "xxs",
},
argTypes: {
variant: {
name: "variant",
type: { name: "string", required: false },
options: ["link", "outline", "solid", "ghost", "unstyled"],
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: ["xs", "sm", "md", "lg"],
description: "Tag height width and horizontal padding",
table: {
type: { summary: "string" },
defaultValue: { summary: "md" },
},
control: {
type: "select",
},
},
isFullWidth: {
name: "isFullWidth",
type: { name: "boolean", required: false },
description: "Full width button",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
control: {
type: "boolean",
},
},
m: {
name: "margin",
type: { name: "string", required: false },
options: spacingOptions,
description: `Margin CSS prop for the Component shorthand for padding.
We also have mt, mb, ml, mr.`,
table: {
type: { summary: "string" },
defaultValue: { summary: "-" },
},
control: {
type: "select",
labels: spacingLabels,
},
},
},
render: (args) => <Button {...args}>Button</Button>,
};
export const Default: StoryObj<ButtonProps> = {
parameters: {
theme: "split",
},
args: {
colorScheme: "teal",
size: "md",
},
argTypes: {
colorScheme: {
name: "colorScheme",
type: { name: "string", required: false },
options: colorSchemes,
description: "The Color Scheme for the button",
table: {
type: { summary: "string" },
defaultValue: { summary: "teal" },
},
control: {
type: "select",
},
},
size: {
name: "size (s)",
type: { name: "string", required: false },
options: ["xs", "sm", "md", "lg"],
description: "Tag height width and horizontal padding",
table: {
type: { summary: "string" },
defaultValue: { summary: "md" },
},
control: {
type: "select",
},
},
},
render: (args) => {
const { colorScheme, size } = args;
return (
<Flex direction="col" gap="lg">
<Flex gap="lg" align="center">
<Button colorScheme={colorScheme} size="xs">
Button
</Button>
<Button colorScheme={colorScheme} size="sm">
Button
</Button>
<Button colorScheme={colorScheme} size="md">
Button
</Button>
<Button colorScheme={colorScheme} size="lg">
Button
</Button>
</Flex>
<Flex gap="lg" align="center">
<Button size={size} colorScheme={colorScheme} variant="solid">
Button
</Button>
<Button size={size} colorScheme={colorScheme} variant="outline">
Button
</Button>
<Button size={size} colorScheme={colorScheme} variant="ghost">
Button
</Button>
<Button size={size} colorScheme={colorScheme} variant="link">
Button
</Button>
</Flex>
<Flex gap="lg" align="center">
<Button
isLoading
size={size}
colorScheme={colorScheme}
variant="solid"
>
Button
</Button>
<Button
isLoading
size={size}
loadingText="Loading...."
colorScheme={colorScheme}
variant="outline"
spinnerPlacement="start"
>
Button
</Button>
<Button
isLoading
size={size}
loadingText="Loading...."
colorScheme={colorScheme}
variant="outline"
spinnerPlacement="end"
>
Button
</Button>
</Flex>
<Flex gap="lg" align="center">
<Button
size={size}
leftIcon={<EmailIcon />}
colorScheme={colorScheme}
variant="solid"
>
Email
</Button>
<Button
size={size}
rightIcon={<ArrowForwardIcon />}
colorScheme={colorScheme}
variant="outline"
>
Call us
</Button>
</Flex>
</Flex>
);
},
};
From the terminal run yarn storybook
and check the output.
Conclusion & next steps
- In this series we managed to clone chakra ui components with its variants and dark theme using plain css.
- We have to create two different classes for the variants, one for light mode and another for dark mode.
- We can avoid this by using css variables for theming, in my next tutorial series, we will implement a small next-ui clone. There we will use only css variables for light and dark modes.
In this tutorial we created a theme able, feature packed Button
component. All the code for this tutorial can be found here. Until next time PEACE.
Top comments (0)