[Vue 3] Implement a Base Input Component the Right Way
Creating large-scale applications can feel like building really tall towers. But, just like those towers are made from lots of tiny simpler…
[Vue 3] Implement a Base Input Component the Right Way
Creating large-scale applications can feel like building really tall towers. But, just like those towers are made from lots of tiny simpler building blocks. One of these basic parts is the input box. It might look basic, but it can get really complicated if you allow to.
Let’s implement a Base Input Component together. It’s a small but important step in building any application.
Naming
Naming things is hard. Thankfully vue style guide has a strong recommendation about naming base components with the prefix Base App or V.
AppInput.vue name sounds great for our use case.
Reset
First, for consistency, we need a CSS reset.
npm install normalize.css
/\* main.css \*/
@import 'normalize.css'
[Why CSS reset is needed?](https://www.webfx.com/blog/web-design/should-you-reset-your-css/)
Basic Styles
Let’s start with a basic implementation of an input. Our goal for now is that all of our inputs have the same look and feel.
<template>
<input type="text" class="input" />
</template>
<style lang="scss" scoped>
.input {
box-sizing: border-box;
width: 100%;
background: #f5f8fa;
border: 1px solid #cccccc;
padding: 8px 20px;
outline: 0;
border-radius: 8px;
}
</style>
The usage is simple
<script setup lang="ts">
import AppInput from "@/components/AppInput.vue";
</script>
<template>
<main>
<h1>Base Input Demo</h1>
<AppInput placeholder="Full name" />
</main>
</template>
Notice that we are using a [fallthrough attribute](https://vuejs.org/guide/components/attrs#fallthrough-attributes) for the placeholder. It will be passed automatically to the root element of our component.
![[Vue 3] Implement a Base Input Component the Right Way](/images/-Vue-3--Implement-a-Base-Input-Component-the-Right-Way-f5ef2f917221/img-5.png)
Search Variation
Next, let’s create another variation to be used for search with a more rounded UI. We will use a prop called pill for that.
<script lang="ts" setup>
defineProps({
pill: {
type: Boolean,
},
});
</script>
<template>
<input
type="text"
class="input"
:class="{
pill,
}"
/>
</template>
<style lang="scss" scoped>
.input {
box-sizing: border-box;
width: 100%;
background: #f5f8fa;
border: 1px solid #cccccc;
padding: 8px 20px;
outline: 0;
border-radius: 8px;
&.pill {
border-radius: 32px;
}
}
</style>
<AppInput pill placeholder="Search" />
The only interesting thing is :class="{ pill }" It will apply the class pill if the prop variable with the same name is truthy.
Remove Hardcoded CSS Values
Before moving on let’s tackle the hardcoded values in our styles. As our application grows larger we should have our pallete, typography, and spacings declared in a centralized place for everyone to use.
This can be done with [CSS variables](https://medium.com/@fadamakis/open-props-a-css-framework-for-the-modern-web-233894019078) or SCSS variables. We will use SCSS this time.
Create a file _variables.scss with the following contents
$color\-light: #f5f8fa;
$color\-border: #cccccc;
@function spacing($factor: 1) {
@return $factor \* 4px;
}
$border\-radius-input: spacing(2);
$border\-radius-pill: spacing(8);
In a real-life scenario breaking this file into multiple is encouraged.
The spacing mixin is a way to enforce the [4px grid](https://designary.com/tip/layout-basics-grid-systems-and-the-4px-grid/) that will help create a pleasant UI with visual rhythm.
We can now update our styles to use the common variables
...
<style lang="scss" scoped>
@import "@/assets/styles/\_variables.scss";
.input {
box-sizing: border-box;
width: 100%;
background: $color-light;
border: 1px solid $color-border;
padding: spacing(2) spacing(5);
outline: 0;
border-radius: $border-radius-input;
&.pill {
border-radius: $border-radius-pill;
}
}
</style>
Alternatively to avoid importing them in every component we can update vite.config.ts as follows:
export default defineConfig({
...
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import "@/assets/styles/\_variables.scss";
`
}
}
}
...
})
Label
Using the placeholder as a label is not accessible and has a bad overall UX. Let’s implement a floating label to solve this. We will use a slot but first, we need to wrap our input in a wrapper and refactor it a bit.
<script lang="ts" setup>
defineOptions({
inheritAttrs: false,
});
defineProps({
pill: {
type: Boolean,
},
});
</script>
<template>
<div class="input-wrapper">
<input
type="text"
class="input"
v-bind="$attrs"
:class="{
'has-label': $slots.label,
pill,
}"
/>
<div class="label">
<slot name="label" />
</div>
</div>
</template>
<style lang="scss" scoped>
.input-wrapper {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
background: $color-light;
border: 1px solid $color-border;
padding: spacing(2) spacing(5);
outline: 0;
border-radius: $border-radius-input;
&.pill {
border-radius: $border-radius-pill;
}
&.has-label {
padding-top: spacing(6);
&::placeholder {
color: transparent;
}
}
}
.label {
position: absolute;
top: 0;
left: 0;
height: 100%;
padding: spacing(4) spacing(5);
pointer-events: none;
transform-origin: 0 0;
transition:
opacity 0.1s ease-in-out,
transform 0.1s ease-in-out;
}
.input:focus ~ .label,
.input:not(:placeholder-shown) ~ .label {
opacity: 0.65;
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
</style>
First, now that our input is no longer the root element we need to set inheritAttrs option to false and manually bind $attrs to it.
The actual label is passed using a slot with the same name. It has an absolute position and a transition is used between states.
![[Vue 3] Implement a Base Input Component the Right Way](/images/-Vue-3--Implement-a-Base-Input-Component-the-Right-Way-f5ef2f917221/img-6.gif)
Prefix & Suffix
Inputs are usually associated with additional information like currency, icons, or even other components with actions. Our base component should support custom content to be shown before or after it using slots.
<script lang="ts" setup>
defineOptions({
inheritAttrs: false,
});
defineProps({
pill: {
type: Boolean,
},
});
</script>
<template>
<div class="input-wrapper">
<input
type="text"
class="input"
v-bind="$attrs"
:class="{
'has-prefix': $slots.prefix,
'has-suffix': $slots.suffix,
'has-label': $slots.label,
pill,
}"
/>
<div class="label">
<slot name="label" />
</div>
<div class="prefix">
<slot name="prefix" />
</div>
<div class="suffix">
<slot name="suffix" />
</div>
</div>
</template>
<style lang="scss" scoped>
.input-wrapper {
position: relative;
+ .input-wrapper {
margin-top: spacing(4);
}
}
.input {
box-sizing: border-box;
width: 100%;
background: $color-light;
border: 1px solid $color-border;
padding: spacing(2) spacing(5);
outline: 0;
border-radius: $border-radius-input;
&.pill {
border-radius: $border-radius-pill;
}
&.has-prefix {
padding-left: spacing(12);
}
&.has-suffix {
padding-right: spacing(12);
}
&.has-label {
padding-top: spacing(6);
&::placeholder {
color: transparent;
}
}
}
.label {
position: absolute;
top: 0;
left: 0;
height: 100%;
padding: spacing(4) spacing(5);
pointer-events: none;
transform-origin: 0 0;
transition:
opacity 0.1s ease-in-out,
transform 0.1s ease-in-out;
}
.input.has-prefix ~ .label {
left: spacing(7);
}
.input:focus ~ .label,
.input:not(:placeholder-shown) ~ .label {
opacity: 0.65;
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
.prefix,
.suffix {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.prefix {
left: spacing(4);
}
.suffix {
right: spacing(4);
}
</style>
This uses the same technique as the label with two additional slots named prefix and suffix.
Listening to Changes
The only missing piece is reacting to changes. [V-model](https://vuejs.org/guide/components/v-model.html#component-v-model) is provided by Vue exactly for this.
Let’s assume a reactive object called userInfo holds our data. We need to add v-model="userInfo.name"
<script setup lang="ts">
import { reactive } from "vue";
const userInfo = reactive({
name: "Fotis",
...
});
</script>
<template>
<main>
<h1>Base Input Demo</h1>
<AppInput v-model="userInfo.name" placeholder="Name">
<template #label> Name </template>
</AppInput>
</main>
</template>
v-model is a shortcut of
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
Let’s update our AppInput to reflect this
<script lang="ts" setup>
defineOptions({
inheritAttrs: false,
});
defineProps({
modelValue: {
type: String,
},
pill: {
type: Boolean,
},
});
const emit = defineEmits(["update:modelValue"]);
const updateValue = (e: Event) => {
emit("update:modelValue", (e.target as HTMLInputElement).value);
};
</script>
<template>
<div class="input-wrapper">
<input
type="text"
class="input"
v-bind="$attrs"
:value="modelValue"
@input="updateValue"
:class="{
'has-prefix': $slots.prefix,
'has-suffix': $slots.suffix,
'has-label': $slots.label,
pill,
}"
/>
...
</div>
</template>
The changes are:
- We added a
modelValueprop - Declared a
update:modelValueemit - Bound both in the value and input attributes of the input element
We can now see that changing the value of the input will be reflected in our reactive userInfo object.
Putting everything together
Lastly, let’s demonstrate the input usage by building a simple form.
<script setup lang="ts">
import AppInput from "@/components/AppInput.vue";
import AppIcon from "@/components/AppIcon.vue";
import { reactive } from "vue";
const userInfo = reactive({
name: "Fotis",
bio: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita deleniti laboriosam eligendi. Incidunt dolores dicta veritatis. Quaerat ad, magnam esse, illo atque delectus minus, nihil adipisci tempora nobis iusto. Excepturi?",
location: "Barcelona",
website: "fadamakis.com",
});
function submitForm() {
alert("Info submitted: " + JSON.stringify(userInfo));
}
</script>
<template>
<main>
<h1>Base Input Demo</h1>
<AppInput v-model="userInfo.name" placeholder="Name">
<template #label> Name </template>
</AppInput>
<AppInput v-model="userInfo.bio" placeholder="Bio">
<template #label> Bio </template>
</AppInput>
<AppInput v-model="userInfo.location" placeholder="Location">
<template #label> Location </template>
<template #prefix>
<AppIcon icon="pin" />
</template>
</AppInput>
<AppInput v-model="userInfo.website" placeholder="Website">
<template #label> Website </template>
<template #prefix>
<AppIcon icon="link" />
</template>
</AppInput>
<button @click="submitForm">
Submit <AppIcon size="2x" icon="arrow-right-circle" />
</button>
<hr />
<AppInput pill name="search" placeholder="Search">
<template #prefix>
<AppIcon icon="search" />
</template>
</AppInput>
<AppInput placeholder="Send message...">
<template #suffix>
<AppIcon size="2x" icon="arrow-right-circle" />
</template>
</AppInput>
</main>
</template>
The result is the following:
You can test it live and take a look at the source code on GitHub.
Feedback on implementation, ideas, or any other comment is very much appreciated.
![[Vue 3] Implement a Base Input Component the Right Way](/images/-Vue-3--Implement-a-Base-Input-Component-the-Right-Way-f5ef2f917221/img-8.png)
ⓘ This article is part of a Base Component Implementation series:
- AppInput.vue (you are here!)
- AppModal.vue
- AppIcon.vue
Any recommendation for future additions? Please leave a comment below.
![[Vue 3] Implement a Base Input Component the Right Way](/images/-Vue-3--Implement-a-Base-Input-Component-the-Right-Way-f5ef2f917221/img-7.png)

