Storybook is a workshop that allows you to isolate your UI components and work on them without the need to link the business logic of the app just to see how they will be renderred. This helps you to focus on the UI components for each of the variations you may need them to be.
You will need to have an existing project on which you will be adding Storybook to it. For this blog post, I will be using the vue-components
project which I had used for this tutorial.
First, you need to run the following command in a terminal inside your existing project root directory:
npx storybook init
This command will create a folder named .storybook
in your project's root directory and a stories
folder inside src
(./src/stories
). After it is done installing, you can check that your new UI component workshop is working well by running:
npm run storybook # or run: yarn storybook
If you have npm version >= 8 installed, you may receive such a warning during the installation of Storybook:
We've detected you are running npm 8.19.2 which has peer dependency semantics which Storybook is incompatible with.
You will be given the option to run the 'npm7' migration on your project.
In this tutorial, we will work on an input and a button component. I already have some components set up and will add the source codes now. You can add them in a folder named components inside the src
folder (./src/components
). Also, you can follow this tutorial if you are interested in learning how to make your own reusable components in Vue 3.
//Input.vue
<script setup lang="ts">
import { reactive } from "vue";
interface Props {
id?: string;
name?: string;
class?: string;
type?: string;
min?: string | number | undefined;
max?: string | number | undefined;
step?: number;
modelValue?: string | number | undefined;
option?: "circle" | "rounded" | "slightlyRounded" | "box" | "line";
border?: string;
width?: string;
padding?: string;
boxShadow?: string;
backgroundColor?: string;
placeholder?: string;
fontSize?: string;
color?: string;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
name: undefined,
class: undefined,
type: undefined,
min: undefined,
max: undefined,
step: undefined,
modelValue: "",
option: undefined,
border: undefined,
width: undefined,
padding: undefined,
boxShadow: undefined,
backgroundColor: undefined,
placeholder: "",
fontSize: undefined,
color: undefined,
readonly: false,
});
const emit = defineEmits<{
(event: "update:modelValue", payload: string | number | undefined): void;
}>();
const updateValue = (e: Event) => {
emit("update:modelValue", (e.target as HTMLInputElement).value);
};
// we will use the following styleObject variable for the input's style attribute
const styleObject = reactive({
width: props.width,
padding: props.padding,
backgroundColor: props.backgroundColor,
boxShadow: props.boxShadow,
fontSize: props.fontSize,
color: props.color,
border: props.border,
});
</script>
<template>
<input
:id="props.id"
:name="props.name"
:type="props.type"
:min="props.min"
:max="props.max"
:step="props.step"
:value="modelValue"
@input="updateValue"
:placeholder="props.placeholder"
:class="
['inputStyle', `inputStyle--${props.option}`, props.class].join(' ')
"
:readOnly="props.readonly"
:style="styleObject"
/>
</template>
<style>
:root {
--inputText-color: inherit;
--inputBorder-color: #000000;
}
input:focus {
outline: none;
}
.inputStyle {
padding: 6px 12px;
border: 0px;
color: var(--inputText-color);
width: 100%;
}
.inputStyle--circle {
border-radius: 50px;
border: 1px solid var(--appColor-black);
}
.inputStyle--rounded {
border-radius: 50px;
border: 1px solid var(--appColor-black);
}
.inputStyle--slightlyRounded {
border-radius: 7px;
border: 1px solid var(--appColor-black);
}
.inputStyle--box {
border-radius: 0;
border: 1px solid var(--appColor-black);
}
.inputStyle--line {
box-shadow: 0px 1px 0px var(--inputBorder-color);
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
}
input[type="number"] {
-moz-appearance: textfield;
}
.activeInputGlow:focus {
outline: none;
border-color: var(--appColor-main);
box-shadow: 0 0 5px var(--appColor-main);
}
</style>
// Button.vue
<script setup lang="ts">
import { reactive } from "vue";
interface Props {
id?: string;
type?: any;
class?: string;
primary?: boolean;
option?: "circle" | "rounded" | "slightlyRounded" | "box" | "line";
border?: string;
width?: string;
padding?: string;
backgroundColor?: string;
fontSize?: string;
color?: string;
label?: string;
hasIcon?: boolean;
iconBtn?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
type: undefined,
class: undefined,
primary: true,
option: undefined,
border: undefined,
backgroundColor: undefined,
width: undefined,
padding: undefined,
fontSize: undefined,
color: undefined,
label: undefined,
hasIcon: false,
iconBtn: false,
});
const mode = props.primary ? "buttonStyle--primary" : "buttonStyle--secondary";
const iconMode = props.iconBtn ? "" : "btnIcon--withIcon";
const styleObject = reactive({
width: props.width,
padding: props.padding,
backgroundColor: props.backgroundColor,
fontSize: props.fontSize,
color: props.color,
border: props.border,
});
</script>
<template>
<button
:id="props.id"
:type="props.type"
:class="
[
'buttonStyle',
`buttonStyle--${props.option}`,
props.class,
mode,
].join(' ')
"
:style="styleObject"
@click="$emit('clicked')"
>
<div v-if="props.hasIcon" class="btnIcon--div">
<span :class="['btnIcon', iconMode].join(' ')"
><slot name="icon"></slot
></span>
<span>{{ label }}</span>
</div>
<span v-else>{{ label }}</span>
</button>
</template>
<style>
:root {
--btnBorder-color: var(--appColor-light);
--btnPrimary-bgColor: var(--appColor-light);
--btnSecondary-bgColor: transparent;
--btnPrimary-textColor: inherit;
--btnSecondary-textColor: inherit;
}
button:focus {
outline: none;
}
.buttonStyle {
box-sizing: border-box;
padding: 6px 25px;
cursor: pointer;
}
.buttonStyle--primary {
border: 0px;
background-color: var(--btnPrimary-bgColor);
color: var(--btnPrimary-textColor);
}
.buttonStyle--secondary {
border: 1px solid var(--btnBorder-color);
background-color: var(--btnSecondary-bgColor);
color: var(--btnSecondary-textColor);
}
.buttonStyle--circle {
padding: 10px;
border-radius: 100px;
}
.buttonStyle--rounded {
border-radius: 50px;
}
.buttonStyle--slightlyRounded {
border-radius: 7px;
}
.buttonStyle--box {
border-radius: 0;
}
.buttonStyle--line {
border-radius: 0;
}
.btnIcon--div {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.btnIcon {
display: inline-flex;
}
.btnIcon--withIcon {
margin-right: 7px;
}
</style>
A story in Storybook is a function that allows us to set up a variation or multiple variations of our existing component(s) and pass props to them accordingly for previewing and editing.
Delete the contents inside the stories
folder (./src/stories
) and create a file named Input.stories.js
in the same folder. We will import the Input component from (./src/components/Input.vue
) and set up some variations of the component for previewing and editing as needed.
// ./src/stories/Input.stories.js
import Input from "../components/Input.vue";
// Here, we define the basics such as title and controls for the custom Input component
export default {
title: "Input",
component: Input,
argTypes: {
// control in storybook is used to define the type of input for that particular prop.
backgroundColor: { control: "color" },
color: { control: "color" },
option: {
control: { type: "select" },
options: ["rounded", "slightlyRounded", "box", "line"],
},
},
};
// Basics for the different variations we will have and their default props are done here. We can change the prop values in the browser after starting the storybook server
const Template = (args) => ({
components: { Input },
setup() {
return { args };
},
// 'args' refer to the dynamic props we will pass to the component. We bind them to the vue component using 'v-bind'
template: '<Input v-bind="args" />',
});
export const Rounded = Template.bind({});
Rounded.args = {
type: "text",
option: "rounded",
backgroundColor: "#f5f5f6",
placeholder: "Hello there",
style: "width:50%;",
};
export const SlightlyRounded = Template.bind({});
SlightlyRounded.args = {
type: "text",
option: "slightlyRounded",
backgroundColor: "#f5f5f6",
placeholder: "Hello there",
style: "width:50%;",
};
export const Box = Template.bind({});
Box.args = {
type: "text",
option: "box",
backgroundColor: "#f5f5f6",
placeholder: "Hello there",
style: "width:50%;",
};
export const Line = Template.bind({});
Line.args = {
type: "text",
option: "line",
backgroundColor: "transparent",
placeholder: "Hello there",
style: "width:50%;",
"box-shadow": "0px 1px 0px #db382a",
};
export const SlightlyRoundedBorder = Template.bind({});
SlightlyRoundedBorder.args = {
type: "text",
option: "slightlyRounded",
border: "1px solid #000000",
backgroundColor: "transparent",
placeholder: "Search",
style: "width:50%;",
};
Next, we add the story for the button component inside the same stories
folder (./src/stories/Button.stories.js
):
// ./src/stories/Button.stories.js
import Button from "../components/Button.vue";
export default {
title: "Button",
component: Button,
argTypes: {
backgroundColor: { control: "color" },
color: { control: "color" },
option: {
control: { type: "select" },
options: ["rounded", "slightlyRounded", "box", "circle"],
},
},
};
const Template = (args) => ({
components: { Button },
setup() {
return { args };
},
template: '<Button v-bind="args" />',
});
const TemplateWithIcon = (args) => ({
components: { Button },
setup() {
return { args };
},
template: `<Button v-bind="args"><template #icon>${args.icon}</template></Button>`,
});
export const Rounded = Template.bind({});
Rounded.args = {
option: "rounded",
label: "Rounded",
backgroundColor: "#db382a",
color: "#e9ece6",
};
export const SlightlyRounded = Template.bind({});
SlightlyRounded.args = {
option: "slightlyRounded",
label: "Slightly Rounded",
backgroundColor: "#db382a",
color: "#e9ece6",
};
export const Box = Template.bind({});
Box.args = {
option: "box",
label: "Box",
backgroundColor: "#db382a",
color: "#e9ece6",
};
export const WithIcon = TemplateWithIcon.bind({});
WithIcon.args = {
option: "rounded",
hasIcon: true,
icon: `<iconify-icon
icon="material-symbols:search"
height="24"
color="inherit"
></iconify-icon>`,
label: "Search",
backgroundColor: "#db382a",
color: "#e9ece6",
};
export const IconButton = TemplateWithIcon.bind({});
IconButton.args = {
option: "circle",
backgroundColor: "#db382a",
color: "#e9ece6",
hasIcon: true,
iconBtn: true,
icon: `<iconify-icon
icon="mdi:bell-notification"
height="24"
color="inherit"
></iconify-icon>`,
};
In the button story, I have used icons from Iconify's CDN. Therefore, I need to have added the import script tag inside the base html for Storybook so that the icons can be seen. You can use Iconify, FontAwesome or your preferred icons website by adding the import script tag at ./.storybook/preview-head.html
:
<script>
window.global = window;
</script>
<!-- Add the import script tag here -->
<script src="https://code.iconify.design/iconify-icon/1.0.3/iconify-icon.min.js"></script>
Now that we have added Storybook to our project and made stories, we can preview all variations we expect for our UI components and fix them up if there is any need to. We can also play around with the props we expect to pass to our components as we view them live in the browser.
This helps us to come up with refined UI components which are also reusable. I hope this guide has helped you set up Storybook with your Vue 3 project.