Vue 3

[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…

Fotis Adamakis
Fotis Adamakis
Senior Software Engineer / Technical Writer
7 min read
January 3, 2024

[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

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

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 modelValue prop
  • Declared a update:modelValue emit
  • 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:

[Vue 3] Implement a Base Input Component the Right Way

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

ⓘ This article is part of a Base Component Implementation series:

Any recommendation for future additions? Please leave a comment below.

Fotis Adamakis

Fotis Adamakis

Senior Software Engineer / Technical Writer

Experienced software engineer writing about front end architecture, accessibility, system design, and developer productivity. Lessons from building and maintaining large-scale frontend applications, with a focus on practical patterns that make codebases easier to understand, scale, and evolve.

Barcelona, Spain