Creating a Formatted Currency Input Component in Vue.js

By Abhinash Khatiwada on June 1, 2023

In this tutorial, we will create a custom currency input component in Vue.js that formats the input value as currency as the user types, but keeps the actual value numeric.

Inputs are prevalent all across the digital medium. We have multitudes of input types available for us that we can utilise for various purposes. In the web, we have simple inputs like text, radio, checkbox as well as more complex ones like colour, slider, date and a ton more available.

However, there isn't a native amount input component in HTML. This is where we can create our own custom input component to handle the amount input. Here, I want to show how we can create a custom amount input component in Vue.js that formats the input as currency as the user types, and the actual value of the input stays numeric.

The Problem

Consider using a simple text input for entering an amount. The user can enter any value in the input, but we want to format the value as currency as the user types. For example, if the user types 1000, we want to display "1,000" in the input. However, we want to keep the actual value of the input as 1000 and not "1,000".

The Plan of Action

Let's try to break down the solution into smaller steps:

  1. Create a Vue component for text input.
  2. Ensure the display value for the input component is controllable.
  3. Create a wrapper component that uses the text input component.
  4. Format the input value as currency as the user types, but keep the numeric value.

Creating the Text Input Component

Let's start by creating a simple text input component that takes a value prop and emits an event with the new value.

I will be using Vue 3's composition API with Typescript and will use Tailwind to style the component. You can use any other styling library or write your own CSS.

I prefer to prefix my components with App to avoid naming conflicts with HTML elements or other components. I know there are people who prefer to use a different naming convention, perhaps using their company's name or their initials. You can use whatever naming convention you prefer - but make sure you stick to it.

components/AppTextInput.vue
<script setup lang="ts">
const props = defineProps<{
  modelValue?: string;
  type?: "text" | "number" | "email" | "password";
  inputMode?: "text" | "numeric" | "decimal" | "email" | "url";
  placeholder?: string;
}>();

const emit = defineEmits<{
  "update:modelValue": [string?];
}>();

const onInput = (event: Event) => {
  const target = event.target as HTMLInputElement;
  emit("update:modelValue", target.value);
};
</script>

<template>
  <input
    :input-mode
    :type
    :placeholder
    :value="modelValue"
    @input="onInput"
    :class="[
      'px-4 py-2',
      'border border-gray-300',
      'hover:border-gray-400',
      'focus:outline-green-500',
      'rounded-md',
    ]"
  />
</template>

What we have done here is created a simple text input component that takes a few props:

  • modelValue: The value of the input. We will use this prop to control the value of the input.
  • type: The type of the input. Default is "text".
  • inputMode: The input mode of the input field. Default is "text". This helps mobile devices to show the correct keyboard - we will need this for numeric input.
  • placeholder: The placeholder for the input.

Now let's try calling this component from our App.vue file. I will do a simple render with just a placeholder as prop. Since all the props declared here are optional, we can call the component without any props as well.

App.vue
<script setup lang="ts">
import AppTextInput from "@/components/AppTextInput.vue";
</script>

<template>
  <AppTextInput placeholder="Type something..." />
</template>

If you run this code, you should see a simple text input field with the placeholder "Type something...".

Result

Controlling the Display Value

Now that we have a simple text input component, let's try to control the display value of the input. We will format the input value as currency as the user types, but keep the actual value as numeric.

To achieve this, we require to create a wrapper component that uses the text input component and formats the value as currency. We will bind the modelValue prop of the text input component to a computed property that formats the value as currency, and monitor the update:modelValue event to update the actual value.

components/AppCurrencyInput.vue
<script setup lang="ts">
import { ref, computed } from "vue";
import AppTextInput from "./AppTextInput.vue";

const value = ref(0);
const displayValue = computed(() => {
  // do not display non numeeric values
  if (Number.isNaN(value.value)) {
    return "";
  }
  // Convert the value to a string and split it into integer and decimal parts
  let parts = value.value.toString().split(".");

  // Format the integer part with commas
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");

  // Recombine the integer and decimal parts
  return parts.join(".");
});

const updateValue = (newValue?: string) => {
  // empty value means user cleared everything, so the value should be 0
  if (!newValue) {
    value.value = 0;
    return;
  }

  // do not update if the value is not a number
  if (Number.isNaN(parseFloat(newValue))) {
    return;
  }

  value.value = parseFloat(newValue.replace(/[^0-9.-]/g, ""));
};
</script>
<template>
  <div>
    <AppTextInput
      :model-value="displayValue"
      @update:model-value="updateValue"
      type="text"
      placeholder="Amount"
      inputmode="decimal"
    />

    Model Value: {{ value }}
  </div>
</template>
Result
Model Value: 0

Got something up and running, aye! We have a simple currency input component that formats the input value as currency as the user types, but keeps the actual value as numeric.

But you might have noticed there are a few issues with this component. Let me list them down:

  1. You can enter alphabets in the input field. We should restrict the input to numbers only.
  2. You can enter multiple decimal points. We should restrict the input to a single decimal point.
  3. The cursor jumps to the end of the input field when you type a number. We should maintain the cursor position when the value changes.

Let's try to fix these issues.

Restricting Input to Numbers Only

For mobile devices without external keyboards attached, inputmode="decimal" should suffice to show the numeric keyboard. However, this doesn't restrict the user from entering alphabets or special characters if they have an external keyboard attached.

Now, one might think, "Why not use the input element's input event to restrict the input?" The input event is fired after the value of the input has changed, so we can't restrict the input using the input event. We need to use the keypress event to restrict the input.

Then, do we need to change the AppTextInput component to handle this? No, we don't. We can handle this in the AppCurrencyInput component itself. We can listen to the keypress event on the input element and prevent the default action if the key pressed is not a number.

components/AppCurrencyInput.vue
<script setup lang="ts">
// ...rest of the code

const preventInvalidInput = (event: KeyboardEvent) => {
  if (!/^\d$/.test(event.key)) {
    event.preventDefault();
  }
};
</script>
<template>
  <div>
    <AppTextInput
      :model-value="displayValue"
      @update:model-value="updateValue"
      type="text"
      placeholder="Amount"
      inputmode="decimal"
      @keypress="preventInvalidInput"
    />

    Model Value: {{ value }}
  </div>
</template>
Result
Model Value: 0

Why does this work?

The root element within the AppTextInput component is an input element. When we listen to the keypress event on the AppCurrencyInput component, the event bubbles up from the input element to the AppCurrencyInput component. We can then prevent the default action if the key pressed is not a number.

Restricting Input to a Single Decimal Point

We can update the preventInvalidInput function to restrict the input to a single decimal point. We can check if the key pressed is a decimal point and if the input already contains a decimal point. If it does, we can prevent the default action.

const preventInvalidInput = (event: KeyboardEvent) => {
  // if the user hit period and the value already contains one, don' allow
  if (
    event.key === "." &&
    (event.currentTarget as HTMLInputElement).value.includes(".")
  ) {
    event.preventDefault();
    return;
  }

  // ensure entered value is either dot or numbers
  if (!/^\d$/.test(event.key) && event.key !== ".") {
    event.preventDefault();
  }
};
Result
Model Value: 0

Maintaining Cursor Position

The cursor jumps to the end of the input field when the value changes. We can maintain the cursor position by saving the cursor position before the value changes and restoring it after the value changes.

We can use the selectionStart and selectionEnd properties of the input element to get the cursor position. We can save these values before the value changes and restore them after the value changes.

To use selectionStart and selectionEnd, we need to get a reference to the native input element. If we use Vue's ref on AppTextInput, we won't get the native input element. Should we then implement cursor position handling in the AppTextInput component? It depends - because you may or may not want a generic component to have this feature.

In my case, I don't want it. So instead, what I will do, is expose the native input element from the AppTextInput component and use it in the AppCurrencyInput component.

This can be done with defineExpose in Vue 3.

I am going to add the following lines within the script tag of AppTextInput.vue component:

components/AppTextInput.vue
const inputEl = ref<HTMLInputElement | null>(null);
defineExpose({
  el: inputEl,
});

And add a ref to the input element in the template:

components/AppTextInput.vue
<input
  :input-mode
  :type
  :placeholder
  :value="modelValue"
  @input="onInput"
  @change="onInput"
  ref="inputEl"
  :class="[
    'px-4 py-2',
    'border border-gray-300',
    'hover:border-gray-400',
    'focus:outline-green-500',
    'rounded-md',
  ]"
/>

Now, when we add a ref to the AppTextInput component in the AppCurrencyInput component, we can access the native input element at ref.value.el since we exposed it as el.

components/AppCurrencyInput.vue
<script setup lang="ts">
// ...rest of the code
const inputEl = ref<typeof AppTextInput | null>(null);

// native input component at
// console.log(inputEl.value.el);
</script>

<template>
  <!-- ...rest of the code -->
  <AppTextInput
    ref="inputEl"
    :model-value="displayValue"
    @update:model-value="updateValue"
    type="text"
    placeholder="Amount"
    inputmode="decimal"
    @keypress="preventInvalidInput"
  />
  <!-- ...rest of the code -->
</template>

Now, we can save the cursor position before the value changes and restore it after the value changes.

components/AppCurrencyInput.vue
const updateValue = async (newValue?: string) => {
  const nativeInput = inputEl.value?.el;

  const selectionStart = nativeInput.selectionStart;
  const selectionEnd = nativeInput.selectionEnd;

  // calculate the position offset caused by commas
  const beforeUpdateValue = nativeInput.value;
  const beforeUpdateCommas = (
    beforeUpdateValue.slice(0, selectionStart).match(/,/g) || []
  ).length;

  // empty value means user cleared everything, so the value should be 0
  if (newValue === "") {
    value.value = 0;

    await nextTick();

    // restore cursor position
    nativeInput.setSelectionRange(selectionStart, selectionStart);
    return;
  }

  // do not update if the value is not a number
  if (!newValue || Number.isNaN(parseFloat(newValue))) {
    return;
  }

  value.value = parseFloat(newValue.replace(/[^0-9.-]/g, ""));

  await nextTick();

  // calculate new comma count and adjust cursor position accordingly
  const afterUpdateValue = nativeInput.value;
  const afterUpdateCommas = (
    afterUpdateValue.slice(0, selectionStart).match(/,/g) || []
  ).length;
  const commaOffset = afterUpdateCommas - beforeUpdateCommas;

  // restore cursor position
  nativeInput.setSelectionRange(
    selectionStart + commaOffset,
    selectionEnd + commaOffset
  );
};

First thing here, notice that I have updated the updateValue function to be an async function. This is because we need to wait for the next tick to get the updated value of the input element.

Next, I have saved the cursor position before the value changes and restored it after the value changes. I have also calculated the offset caused by the commas and adjusted the cursor position accordingly.

And that's it! We have a fully functional currency input component that formats the input value as currency as the user types, but keeps the actual value as numeric.

Result
Model Value: 0

Conclusion

We have created a custom currency input component in Vue.js that formats the input value as currency as the user types, but keeps the actual value as numeric. We have also restricted the input to numbers only, restricted the input to a single decimal point, and maintained the cursor position when the value changes.

I have tried to keep the process of development just the way how I would approach it in real life. Whenever building something, start from the basics and keep adding features one by one. This helps in understanding the problem better and also helps in debugging if something goes wrong along the way.

I hope you found this tutorial helpful. If you have any questions or suggestions, feel free to reach out to me on Twitter. I would love to hear from you.

© 2025. All Rights Reserved.