Introduction
In the landscape of modern front-end development, reusability and consistency are paramount. When building complex forms in Vue 3, relying solely on native HTML input elements often leads to code duplication and inconsistent styling. Creating custom input components allows developers to encapsulate logic, styling, and validation into a single, maintainable unit. This guide explores the technical implementation of high-quality input components using Vue 3’s Composition API and the latest defineModel macro.
Understanding Two-Way Data Binding
At the heart of any Vue input component is the v-model directive. Historically, v-model was syntactic sugar for a prop (modelValue) and an event (update:modelValue). While this mechanism still functions, Vue 3.4 introduced defineModel, which significantly simplifies the implementation of two-way data binding.
The Mechanics of defineModel
The defineModel macro automatically declares a prop and provides a ref that can be mutated. When the value of this ref changes, Vue emits the corresponding update event to the parent component.
<script setup>
const model = defineModel();
</script>
<template>
<input v-model="model" />
</template>
This abstraction reduces boilerplate and makes the component's intent clearer to other developers reading the codebase.
Building a Robust Base Input Component
A production-ready input component needs more than just a text field. It requires a label, proper ID management for accessibility, and a way to display error messages.
1. Basic Structure and Props
Let’s define a BaseInput.vue component that accepts a label and a placeholder. We will use the Composition API with <script setup>.
<script setup>
import { useId } from 'vue';
const props = defineProps({
label: {
type: String,
default: ''
},
error: {
type: String,
default: ''
}
});
const model = defineModel({ type: String });
const inputId = useId(); // Available in Vue 3.5+
</script>
<template>
<div class="input-wrapper">
<label v-if="label" :for="inputId" class="input-label">
{{ label }}
</label>
<input
:id="inputId"
v-model="model"
class="input-field"
v-bind="$attrs"
:class="{ 'input-error': error }"
/>
<span v-if="error" class="error-message">{{ error }}</span>
</div>
</template>
2. Handling Attribute Inheritance
By default, Vue applies attributes passed to a component (like type="password", placeholder, or required) to the component's root element. In our BaseInput, the root is a <div>. This is often undesirable because we want these attributes on the <input> tag itself.
To fix this, we set inheritAttrs: false and use v-bind="$attrs" on the input element. This ensures that any standard HTML attribute provided by the parent is correctly delegated to the internal input.
<script>
export default {
inheritAttrs: false
}
</script>
<script setup>
// ... logic here
</script>
Advanced Features: Validation and Events
Beyond simple data binding, professional input components must handle state transitions and validation logic.
Handling Blur and Focus
Oftentimes, you only want to validate an input after the user has interacted with it. We can proxy native events to the parent or handle them internally to toggle a "touched" state.
<template>
<input
v-model="model"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
class="input-field"
/>
</template>
Styling States
A technical implementation is incomplete without addressing the visual feedback loop. Use CSS classes to represent different states:
- Focus State:
:focuspseudo-class for keyboard navigation. - Error State: A specific class applied when the
errorprop is present. - Disabled State:
:disabledpseudo-class to match the native attribute.
Best Practices for Custom Inputs
To ensure your components are scalable, follow these industry best practices:
- Accessibility (a11y): Always link labels to inputs using the
forandidattributes. Usearia-describedbyto link error messages to the input. - Type Safety: If using TypeScript, define the types for your props and model to provide better developer experience (DX) and catch bugs at build time.
- Controlled vs. Uncontrolled: Stick to the "controlled" pattern where the parent component manages the state via
v-model. - Composition over Configuration: Instead of adding dozens of props for icons or suffixes, use slots (
<slot name="prefix">) to allow for flexible content injection.
Conclusion
Creating a custom input component in Vue 3 is a fundamental skill that bridges the gap between basic UI and enterprise-grade applications. By leveraging defineModel, managing attribute inheritance with $attrs, and prioritizing accessibility, you can build a library of form controls that are both powerful and easy to maintain. As your application grows, these modular components will become the building blocks of a consistent and user-friendly interface.