Alpine.js x-mask: Input Formatting for Phone, IBAN, and Date
AI generated
x-data
Alpine
Alpine.js · x-mask · Input Formatting · Hyva
Alpine.js x-mask: Input Formatting
for Phone, IBAN, and Date

Input fields that format themselves automatically feel more professional and reduce user mistakes. x-mask does exactly that, live, without a library, directly in Alpine.js. This article explains static and dynamic masks, character classes, and how to integrate them into Hyva forms.

10 min read x-mask · dynamic masks · validation · Alpine.js Plugin Alpine.js 3.x · Hyva Themes · Magento 2

1. What x-mask Actually Solves

Form fields are the most critical touchpoint between a user and the backend. A phone number field that accepts raw digit strings without formatting produces data in twenty different shapes, depending on how the user fills it in. The backend then has to normalize all these variants, or validation rules end up failing on otherwise correct input. x-mask moves the formatting logic to the right place: directly into the input field, visible to the user, without server-side cleanup.

The Alpine.js x-mask plugin works fundamentally differently from regular expression validation. Instead of checking whether a finished input matches a pattern, x-mask actively reshapes the input while the user types. Non-matching characters are ignored, and separators such as spaces, hyphens, and slashes are inserted automatically. The user only types digits and letters, and the field formats itself. This reduces input mistakes, improves the UX, and delivers consistent data to the backend.

In the Hyva ecosystem for Magento 2, x-mask is especially valuable because Alpine.js is already present and no additional JavaScript bundle needs to be loaded. Integration into checkout forms, address fields, and payment inputs follows the same patterns as every other Alpine.js directive: declarative in the template, without separate JavaScript code.

2. Installing and Loading the Plugin

The x-mask plugin is an official Alpine.js plugin and belongs to the core ecosystem. It is not loaded automatically with Alpine.js but must be included separately. In a standard Hyva setup this happens via a <script> tag that loads before the Alpine.js initialization script. The order matters: the plugin has to be registered before Alpine initializes, otherwise Alpine does not recognize the x-mask directive and throws a silent error.

In Hyva themes with the CSP module, every inline script must be registered following the CSP pattern. That means after every <script> block, a call to $hyvaCsp->registerInlineScript() follows in the PHP template. For external scripts loaded from a CDN, the matching hash or domain goes onto the CSP whitelist. In production projects it is recommended to install the plugin via npm and integrate it into the Tailwind build so it ships together with the rest of the frontend bundle.


// Installation via npm (recommended for production)
// npm install @alpinejs/mask

// In your main JS entrypoint (e.g., web/tailwind/main.js):
import Alpine from 'alpinejs'
import mask from '@alpinejs/mask'

Alpine.plugin(mask)
Alpine.start()

// Alternative: CDN via script tag (development / prototyping)
// Load BEFORE alpinejs:
// <script src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

// Hyvä CSP pattern in .phtml template:
// After every inline script block:
// <?= $hyvaCsp->registerInlineScript() ?>

In a Hyva phtml file, the plugin is typically included as a dependency through the Hyva module system. Important: the plugin must not be registered twice if multiple components on the same page use x-mask. Plugin registration belongs in the global layout XML, not in each individual component. Using Hyva_Theme::module/alpine/plugins.phtml as an entry point lets plugins be managed centrally.

3. Understanding Character Classes and Placeholders

x-mask uses a minimalist mask language with two primary placeholder types. The character 9 in the mask stands for any digit (0 to 9). The character a stands for any letter (a to z, A to Z). The asterisk * accepts any alphanumeric character. Every other character in the mask is treated as a literal and inserted automatically: spaces, hyphens, periods, parentheses, and slashes appear automatically at the correct position without the user having to type them.

This simple language covers most everyday cases. A German phone number in the format +49 (0) 123 456789 can be expressed as the mask +99 (9) 999 999999. A date in the German format DD.MM.YYYY becomes the mask 99.99.9999. For more complex requirements, such as IBANs that vary in length by country, x-mask offers the dynamic variant x-mask:dynamic, where a JavaScript function returns the matching mask in real time.

One important difference from other masking libraries: x-mask manipulates the actual value of the input, not just its display. The value bound to x-model and sent to the backend includes the formatting, separators and all. Anyone who needs the raw, unformatted value has to strip it out themselves in Alpine data, for example with an x-effect that removes the separators.

4. Formatting Phone Numbers Live

Phone numbers are the classic use case for input masks. The challenge lies in the variability: area codes, country codes, and number lengths differ significantly. For forms with a known format, such as a German customer form with national numbers, a static mask works great. The user types digits, and the field automatically inserts parentheses and spaces.

One quirk with phone numbers: the plus sign at the start of an international dialing code is not a placeholder but a literal. It appears automatically as soon as the user starts typing. If that is undesirable, because some users enter numbers without a country code, the dynamic mask is a better fit, switching between national and international formatting depending on the first character typed.


<!-- Static phone mask: German national format +49 (0) 123 4567890 -->
<div x-data="{ phone: '', phoneRaw: '' }">
  <input
    type="tel"
    x-mask="+99 (9) 999 9999999"
    x-model="phone"
    x-on:input="phoneRaw = phone.replace(/\D/g, '')"
    placeholder="+49 (0) 123 4567890"
    class="border rounded px-3 py-2 w-full"
  >
  <!-- phoneRaw contains only digits for backend submission -->
  <input type="hidden" name="phone_raw" :value="phoneRaw">
  <p x-show="phone.length > 0 && phone.replace(/\D/g,'').length < 10"
     class="text-red-600 text-sm mt-1">
    Bitte geben Sie eine vollständige Telefonnummer ein.
  </p>
</div>

<!-- Dynamic mask: switches between national and international -->
<div x-data="{ phone: '' }">
  <input
    type="tel"
    x-mask:dynamic="phone.startsWith('+') ? '+99 999 99999999' : '(999) 999-9999'"
    x-model="phone"
    placeholder="Telefon eingeben..."
    class="border rounded px-3 py-2 w-full"
  >
</div>

5. IBAN Input With a Dynamic Mask

IBANs are one of the technically most demanding masking tasks because their length depends on the country code. A German IBAN has 22 characters, a Swiss one 21, a French one 27. The classic grouped format with a space every four characters, DE89 3704 0044 0532 0130 00, is far more readable for users than an unbroken string. With x-mask:dynamic a function can determine the correct IBAN length from the first two letters typed and adjust the mask accordingly.

The implementation uses a JavaScript object with country codes and their IBAN lengths. The dynamic function receives the current input value as an argument and returns the matching mask. As the user types, the mask switches automatically as soon as the first two characters form a known country code. For unknown country codes, a fallback mask of maximum length applies.


<div x-data="{
  iban: '',
  ibanLengths: {
    DE: 22, AT: 20, CH: 21, FR: 27, NL: 18, BE: 16,
    ES: 24, IT: 27, PL: 28, GB: 22, LU: 20, DK: 18
  },
  getMask(val) {
    // Extract country code from current input (letters only)
    const cc = val.replace(/[^a-zA-Z]/g, '').substring(0, 2).toUpperCase()
    const len = this.ibanLengths[cc] || 34
    // Build grouped mask: groups of 4 separated by spaces
    const groups = Math.ceil(len / 4)
    const parts = []
    let remaining = len
    // First group starts with 2 letters + 2 digits
    parts.push('aa99')
    remaining -= 4
    while (remaining > 0) {
      parts.push('9'.repeat(Math.min(4, remaining)))
      remaining -= 4
    }
    return parts.join(' ')
  }
}">
  <input
    type="text"
    x-mask:dynamic="getMask(iban)"
    x-model="iban"
    placeholder="DE89 3704 0044 0532 0130 00"
    class="border rounded px-3 py-2 w-full font-mono"
    autocomplete="off"
  >
  <p class="text-xs text-slate-500 mt-1">
    IBAN wird automatisch formatiert. Nur Buchstaben und Ziffern eingeben.
  </p>
</div>

6. Masking Date and Time Fields

Date fields built with native <input type="date"> elements render differently from browser to browser and do not offer a consistent German date format. Many projects therefore use text inputs with a date mask that enforces the format DD.MM.YYYY. That feels more familiar to German users from a UX standpoint and gives the developer full control over validation and further processing.

With x-mask, a date field is implemented in a single line: x-mask="99.99.9999". The periods are inserted automatically, and the user only types digits. For validation, an x-effect or a computed field checks whether the day, month, and year hold plausible values. The same principle applies to time values: x-mask="99:99" formats hours and minutes, and x-mask="99:99:99" adds seconds.


<div x-data="{
  birthdate: '',
  get isValidDate() {
    if (this.birthdate.length < 10) return null // not yet complete
    const [dd, mm, yyyy] = this.birthdate.split('.').map(Number)
    if (dd < 1 || dd > 31 || mm < 1 || mm > 12 || yyyy < 1900) return false
    const d = new Date(yyyy, mm - 1, dd)
    return d.getFullYear() === yyyy && d.getMonth() === mm - 1 && d.getDate() === dd
  },
  get isoDate() {
    if (!this.isValidDate) return ''
    const [dd, mm, yyyy] = this.birthdate.split('.')
    return `${yyyy}-${mm}-${dd}` // ISO format for backend
  }
}">
  <label class="block text-sm font-medium mb-1">Geburtsdatum</label>
  <input
    type="text"
    x-mask="99.99.9999"
    x-model="birthdate"
    placeholder="TT.MM.JJJJ"
    :class="isValidDate === false ? 'border-red-500' : 'border-slate-300'"
    class="border rounded px-3 py-2 w-full"
  >
  <p x-show="isValidDate === false" class="text-red-600 text-sm mt-1">
    Ungültiges Datum. Bitte Format TT.MM.JJJJ verwenden.
  </p>
  <input type="hidden" name="birthdate_iso" :value="isoDate">
</div>

7. Dynamic Masks With x-mask:dynamic

x-mask:dynamic is the most powerful variant of the plugin. Instead of a static mask string, it expects a JavaScript expression that calls a function or directly returns a mask. The function receives the current raw value of the field as an argument and can therefore return a different mask depending on what has been typed so far. That enables scenarios static masks cannot solve: credit card numbers with different formats depending on the card type, postal codes with varying length depending on the country, or order numbers with changing prefixes.

The credit card is the prime example: Visa and Mastercard have 16 digits in the format 9999 9999 9999 9999, while American Express has 15 digits in the format 9999 999999 99999. The first four digits reveal the card type. With x-mask:dynamic, Alpine detects the card type while the user types and automatically applies the correct format. That saves the user confusion and saves the developer backend normalization logic.

8. Combining Validation With x-mask

x-mask and validation complement each other, but they do not replace one another. The mask makes sure the format is right, but it does not check whether the value is semantically correct. A masked IBAN is not proof that the account exists. A masked date is not proof that the day exists within the given month (February 31 is syntactically possible but semantically wrong). Validation logic therefore has to be implemented on top.

Alpine.js offers x-effect, computed properties via getters, and @input handlers for exactly this. A proven pattern: the mask formats the field, a getter derives isValid from the formatted value, and a Tailwind class binding shows the state visually. When the form is submitted, an @submit.prevent handler checks all validation states and blocks submission if errors are present. The combination of x-mask for format and Alpine data for semantics covers both layers.

9. x-mask Compared to Alternatives

Before x-mask appeared as an official Alpine.js plugin, developers reached for external libraries such as IMask, Cleave.js, or inputmask. These libraries are more powerful, but they bring their own bundle size, have to be wired into Alpine manually, and cannot be used declaratively. x-mask weighs about 1 KB, needs no JavaScript code outside the template, and follows the Alpine principle of HTML-first interactivity.

Criterion x-mask (Alpine) IMask.js Cleave.js
Bundle size ~1 KB ~25 KB ~12 KB
Declarative in HTML Yes (x-mask) No (JS required) No (JS required)
Dynamic masks Yes (x-mask:dynamic) Yes (more complex) Limited
Alpine.js integration Native Manual via x-ref Manual via x-ref
Regex masks Limited Full Limited

For the overwhelming majority of frontend forms in Hyva projects, x-mask is the right choice. Its small size, declarative syntax, and seamless Alpine.js integration outweigh the missing regex support. For special cases such as complex SWIFT codes or regex-based free-text masks, IMask can be wired in via an Alpine lifecycle hook (x-init with $refs) without replacing x-mask.

Mironsoft

Alpine.js · Hyva Themes · Magento 2 Frontend Development

Forms that don't frustrate your users?

We build intelligent input masks, real time validation, and accessible form components for Hyva Magento projects, using Alpine.js and without external dependencies.

Input Masks

x-mask for phone, IBAN, date, and credit card: declarative and easy to maintain

Validation Logic

Real time validation with Alpine.js getters and visual feedback without a page reload

Checkout Integration

Optimizing forms in the Magento checkout with Hyva to improve UX and conversion

10. Summary

Alpine.js x-mask is the most pragmatic solution for input formatting in the Alpine.js ecosystem. The static variant with x-mask="99.99.9999" covers dates, postal codes, and simple phone numbers. The dynamic variant x-mask:dynamic enables context dependent masks for IBANs, credit card numbers, and country specific formats. The plugin weighs about 1 KB, is fully declarative, and integrates seamlessly into existing Alpine.js components without a single line of extra JavaScript code outside the template.

The most important principle when using x-mask: the mask formats, it does not validate. Semantic validation, whether the date is valid, whether the IBAN check digit is correct, has to be implemented separately. Alpine.js offers getters, x-effect, and @submit.prevent handlers for that. Combining both approaches produces forms that are intuitive for users, maintainable for developers, and consistent for backends.

Alpine.js x-mask: The Essentials at a Glance

Character Classes

9 = digit, a = letter, * = alphanumeric. Every other character is a literal and gets inserted automatically.

Dynamic Masks

x-mask:dynamic accepts a function that receives the current value and returns the matching mask, for IBANs, credit cards, and country specific formats.

Value vs. Display

x-mask formats the actual input value, separators included. For raw digits, use an @input handler with replace(/\D/g, '').

Plugin Order

The mask plugin must be registered before Alpine.start(). In Hyva: through the central plugin layout, not individually in every component.

11. FAQ: Alpine.js x-mask

1What is Alpine.js x-mask?
Official Alpine.js plugin that automatically formats input fields while the user types: inserting separators, ignoring invalid characters, without an external library.
2What do 9, a, and * mean in the mask?
9 = digit, a = letter, * = alphanumeric. Every other character is a literal and gets inserted automatically.
3Difference between x-mask and x-mask:dynamic?
x-mask expects a static mask. x-mask:dynamic calls a function that receives the current value and returns the matching mask, for IBANs, credit cards, and more.
4Does x-mask also validate input?
No. Format only. Implement semantic validation (valid date, IBAN check digit) separately with Alpine.js getters and @submit.prevent.
5What does x-model contain when a mask is active?
The formatted value with separators. For raw digits, use an @input handler: phone.replace(/\D/g, '').
6x-mask in Hyva with CSP?
Include the plugin via npm, register it before Alpine.start(). In phtml templates, call $hyvaCsp->registerInlineScript() after every inline script.
7Credit card formats with x-mask:dynamic?
Yes. The function checks the first digits: Visa/MC uses '9999 9999 9999 9999', Amex uses '9999 999999 99999'. The mask switches automatically.
8How large is the x-mask plugin?
About 1 KB minified and gzipped. IMask is about 25 KB, Cleave.js about 12 KB: x-mask is by far the leanest option.
9Does the a placeholder support umlauts?
By default: only a to z and A to Z. For umlauts, use x-mask:dynamic with custom character class logic.
10Loading order of the plugin?
The plugin must be registered before Alpine.start(). CDN: load the plugin script before the Alpine script. npm: call Alpine.plugin(mask) before Alpine.start().