Having your own set of reusable components is sometimes the best way to go since you know exactly what props your components require. You also don't spend time on reading documentation and you can customize them exactly how you want. In this blog post, I will show you how you can make your own reusable form components specifically an input component (for text & numbers) and a button component.
You can find the github repository here.
We can create a Vue 3 project by typing the following in a terminal:
npm init vue@latest
After running the command you will be presented with prompts for several options, choose the options that suit your project:
√ Project name: ... project-name
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
Scaffolding project in ./project-name...
Done.
NB: replace
project-name
with a suitable name for your project
Now change directory to where your project is, install dependencies and start the development server:
cd project-name
npm install # or run: npm i
npm run dev
This is completely optional, you don't have to remove the files & folders if you don't want to. I am doing this so that we don't have a lot of files in the way for this tutorial.
Now that you've installed Vue, opened the project and are running the development server, we can remove the scaffolding (default) files that Vue creates for us.
Locate the views
folder inside the src
folder (./src/views
) and delete its contents. Locate the components
folder, also inside the src
folder (./src/components
) and delete the contents as well.
You can create a new Home.vue
file inside the views
folder (./src/views/Home.vue
) where we will see the components which we will be developing. Add the following boilerplate code to it:
<script setup></script>
<template>
</template>
<style></style>
Next add files for input & button components inside the components
folder (./src/components/Input.vue
& ./src/components/Button.vue
). Add the above boilerplate code to both files.
At this point if you deleted the scaffolding files, you probably saw an error on files not existing (this is because we deleted the default files). Let's fix this by opening the index.js
file in the router
folder (./src/router/index.js
) (if you selected yes for the '√ Add Vue Router for Single Page Application development?' option). Change the file's content to point to the new Home.vue
file we created for the base path (/
).
import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: Home,
},
{ path: "/:catchAll(.*)", name: "home", component: Home }, // you can change this to point to your 404 error page if you make one in future for any routes that aren't in the app
// {
// path: '/about',
// name: 'about',
// // route level code-splitting
// // this generates a separate chunk (About.[hash].js) for this route
// // which is lazy-loaded when the route is visited.
// component: () => import('../views/AboutView.vue')
// }
],
});
export default router;
Next, in the main App.vue
file found in src
folder (./src/App.vue
), replace the contents with the following:
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="">
<RouterView />
</div>
</template>
<style></style>
Now your error on files not found should go away.
We can start by first adding custom theme colors & font inside our main CSS file. You can make an app.css
file inside the assets
folder (./src/assets
) and add the following inside the file (you can choose different colors & font):
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap");
:root {
--appColor-black: #000000;
--appColor-dark: #25262c;
--appColor-darkAccent: #8e7778;
--appColor-main: #db382a;
--appColor-lightAccent: #d09d5e;
--appColor-light: #e9ece6;
}
* {
font-family: "Poppins", sans-serif;
}
Next, we make sure that the CSS file is imported and being used by doing the following inside the main.js
file (./src/main.js
):
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./assets/app.css"; // Add this line
const app = createApp(App);
app.use(router);
app.mount("#app");
Open the Input.vue
file (./src/components/Input.vue
) and start by adding styling for the input components (inside the <style>
tag):
<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;
}
.inputStyle--rounded {
border-radius: 50px;
}
.inputStyle--slightlyRounded {
border-radius: 7px;
}
.inputStyle--box {
border-radius: 0;
}
.inputStyle--line {
box-shadow: 0px 1px 0px var(--inputBorder-color);
}
</style>
Next, we declare our props using pure type annotations with TypeScript and define the props and default values like this (inside the <script>
tag):
<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>
Finally, we add the actual input element in the file and pass the props (props are assigned dynamically with v-bind or its : shortcut) that will be received by the component like this (inside the <template>
tag):
<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>
Your Input.vue
file should look like this:
<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>
Now we can test whether our component is working fine by importing it in Home.vue
or any other file we want to use it in like this:
<script setup>
import Input from "../components/Input.vue";
let message= "Value from v-model";
</script>
<template>
<div class="">
<Input
class=""
type="text"
option="slightlyRounded"
backgroundColor="transparent"
color="var(--appColor-dark)"
placeholder="Hello there"
border="1px solid var(--appColor-dark)" // use this to specify border width or color instead of using default styles for "circle" | "rounded" | "slightlyRounded" | "box" inputs
v-model="message"
/>
</div>
<div class="">
<Input
class=""
type="text"
option="line"
backgroundColor="transparent"
color="var(--appColor-dark)"
placeholder="Hello there"
box-shadow= "0px 1px 0px var(--inputBorder-color)" // use this to specify border width or color instead of using default styles for "line" inputs
/>
</div>
</template>
<style></style>
Similar to the Input component, we make the styles then define the props we expect then assign the props to the actual button element. What's different is that we add a slot to pass an Icon prop to the button incase we want our button to have an icon. We can allow components to be passed as props using the <slot>
tag in the component like this (Button.vue
):
// Button.vue
<div v-if="props.hasIcon" class="btnIcon--div">
<span :class="['btnIcon', iconMode].join(' ')">
<slot name="icon"></slot>
</span>
<span>{{ label }}</span>
</div>
We then pass the icon component inside the <template>
tag like this in Home.vue
:
// Home.vue
<Button
type="button"
class=""
option="box"
label="Log out"
backgroundColor="var(--appColor-dark)"
color="var(--appColor-light)"
@clicked="doSomething"
hasIcon
>
<template #icon>
<Icon
class=""
icon="material-symbols:logout-rounded"
height="24"
color="inherit"/>
</template>
</Button>
Take note of the
<slot name=icon>
in the component and the<template #icon>
in the file where we have used the component.
The
icon
is what we've used to know where the icon component will go inside the Button component. If you have different component props to pass, you can use different names
Your Button.vue file will be similar to this:
<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>
You can import the Button component in the Home.vue
file and experiment with the different options I have put or feel free to change the props to suit what you need. Here's a sample of how the Home.vue
can look like:
<script setup>
import Input from "../components/Input.vue";
import Button from "../components/Button.vue";
import { Icon } from "@iconify/vue";
let message= "Value from v-model";
const doSomething = () => {
alert("Hello there!");
};
</script>
<template>
<div class="w-full p-5 flex flex-col">
<div class="">
<label>Slightly rounded</label>
<Input
class=""
type="text"
option="slightlyRounded"
backgroundColor="transparent"
color="var(--appColor-black)"
placeholder="Hello there"
border="1px solid var(--appColor-main)"
v-model="message"
/>
</div>
<div class="pt-3">
<label>Line</label>
<Input
name="input-with-line"
class=""
type="email"
option="line"
backgroundColor="transparent"
color="var(--appColor-black)"
placeholder="Hello there"
/>
</div>
<div class="pt-5">
<Button
type="button"
class=""
option="slightlyRounded"
label="Get started"
backgroundColor="var(--appColor-main)"
color="var(--appColor-light)"
@clicked="doSomething"
/>
</div>
<div class="pt-5">
<Button
type="button"
class=""
option="circle"
backgroundColor="var(--appColor-main)"
color="var(--appColor-light)"
@clicked="doSomething"
hasIcon
iconBtn
><template #icon
><Icon
class=""
icon="mdi:bell-notification"
height="24"
color="var(--appColor-light)" /></template
></Button>
</div>
<div class="pt-5">
<Button
type="button"
class=""
option="box"
label="Log out"
backgroundColor="var(--appColor-dark)"
color="var(--appColor-light)"
@clicked="doSomething"
hasIcon
><template #icon
><Icon
class=""
icon="material-symbols:logout-rounded"
height="24"
color="inherit" /></template
></Button>
</div>
</div>
</template>
<style></style>
I have put the vue project I used for this tutorial here. You can clone it and follow the instructions in the README.md file to get a better understanding if the article wasn't clear enough. Also you can let me know in the comments if the article wasn't clear enough.