← back to blog
2026-03-10 open-source

Building MaskDial: A Phone Number Library with React, Vue, and Full Accessibility

Phone number inputs are deceptively complex. Different countries have different formats, users paste numbers in random formats, and most existing libraries either lack framework support or ignore accessibility entirely. I built MaskDial to solve all of this in one package.

What MaskDial Does

  • React hooks/components and Vue 3 composables from a single package
  • Country selector with 200+ countries and flag emojis
  • Zod and Yup validation schemas
  • Full WCAG 2.2 compliance with ARIA, keyboard navigation, and screen reader support
  • Smart cursor positioning that doesn’t jump around during formatting
  • Powered by libphonenumber-js for accurate formatting

The Architecture

The library is structured in layers:

maskdial/
├── core/              # Pure formatting logic (no framework)
│   ├── formatter.ts   # Phone number formatting engine
│   ├── countries.ts   # Country data (codes, patterns, flags)
│   ├── cursor.ts      # Smart cursor position tracking
│   └── validators.ts  # Zod/Yup schema generators
├── react/             # React adapter
│   ├── usePhoneInput.ts
│   ├── PhoneInput.tsx
│   └── CountrySelector.tsx
├── vue/               # Vue adapter
│   ├── usePhoneInput.ts
│   ├── PhoneInput.vue
│   └── CountrySelector.vue
└── vanilla/           # Vanilla JS adapter
    └── index.ts

The core has zero framework dependencies. Each adapter is a thin wrapper that connects the formatting engine to the framework’s reactivity system.

The Cursor Problem

The hardest part of building a formatted input is cursor positioning. When a user types “123” and it formats to “+1 (23”, where should the cursor be? Most libraries get this wrong — the cursor jumps to the end after every keystroke.

My solution tracks the cursor position relative to the raw digits, not the formatted string:

interface CursorState {
  rawPosition: number;
  formatted: string;
  raw: string;
}

function calculateCursorPosition(
  prevState: CursorState,
  newRaw: string,
  newFormatted: string,
  inputCursor: number
): number {
  // Count how many raw digits are before the cursor
  let rawDigitsBefore = 0;
  let i = 0;

  while (i < inputCursor && i < newFormatted.length) {
    if (/\d/.test(newFormatted[i])) {
      rawDigitsBefore++;
    }
    i++;
  }

  // Find where that many raw digits end in the new formatted string
  let digitCount = 0;
  for (let pos = 0; pos < newFormatted.length; pos++) {
    if (/\d/.test(newFormatted[pos])) {
      digitCount++;
      if (digitCount === rawDigitsBefore) {
        return pos + 1;
      }
    }
  }

  return newFormatted.length;
}

This approach means the cursor always stays next to the digit the user just typed, regardless of what formatting characters (spaces, dashes, parentheses) get inserted around it.

React Integration

The React hook provides a clean API:

import { usePhoneInput } from 'maskdial/react';

function PhoneForm() {
  const {
    inputProps,
    country,
    setCountry,
    phoneNumber,
    isValid,
    formattedValue,
  } = usePhoneInput({
    defaultCountry: 'IN',
    onChange: (value) => console.log('Phone:', value),
  });

  return (
    <div>
      <CountrySelector
        value={country}
        onChange={setCountry}
      />
      <input {...inputProps} />
      {!isValid && <span>Invalid phone number</span>}
    </div>
  );
}

The hook handles all the complexity — formatting, cursor positioning, country detection from the number prefix, and validation. The component just spreads inputProps onto a regular <input>.

Form Validation

MaskDial ships Zod and Yup schemas so validation integrates naturally with form libraries:

import { phoneSchema } from 'maskdial/validators/zod';
import { z } from 'zod';

const contactForm = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  phone: phoneSchema({
    country: 'IN',
    required: true,
    mobile: true,
  }),
});

// With Yup
import { phoneSchema as yupPhone } from 'maskdial/validators/yup';
import * as yup from 'yup';

const schema = yup.object({
  phone: yupPhone({ country: 'IN' }).required(),
});

Accessibility (WCAG 2.2)

Accessibility wasn’t an afterthought — it was a core requirement. The country selector is fully keyboard-navigable:

  • Arrow keys to browse countries
  • Type to search by country name or code
  • Enter to select
  • Escape to close
  • Tab moves focus naturally
  • ARIA labels on every interactive element
  • Screen reader announcements for country changes and validation errors
<div
  role="combobox"
  aria-expanded={isOpen}
  aria-haspopup="listbox"
  aria-label="Select country"
>
  <button
    aria-label={`Selected country: ${country.name} (${country.dialCode})`}
    onClick={toggle}
  >
    {country.flag} {country.dialCode}
  </button>
  {isOpen && (
    <ul role="listbox" aria-label="Countries">
      {countries.map((c, i) => (
        <li
          role="option"
          aria-selected={c.code === country.code}
          tabIndex={-1}
        >
          {c.flag} {c.name} ({c.dialCode})
        </li>
      ))}
    </ul>
  )}
</div>

Testing

The library has extensive tests covering:

  • Every country’s formatting pattern
  • Cursor position across insertions, deletions, and paste operations
  • Keyboard navigation for the country selector
  • Screen reader output verification
  • Edge cases: empty input, invalid characters, switching countries mid-input
describe('Indian phone formatting', () => {
  it('formats a mobile number', () => {
    expect(format('9843677492', 'IN')).toBe('+91 98436 77492');
  });

  it('handles partial input', () => {
    expect(format('984', 'IN')).toBe('+91 984');
  });

  it('detects country from prefix', () => {
    expect(detectCountry('+919843677492')).toBe('IN');
  });
});

What I Learned

Building a library that works across React, Vue, and vanilla JS forced me to think about clean separation of concerns. The core formatting engine knows nothing about React or Vue — it takes a string in and returns a formatted string out. The framework adapters are under 100 lines each.

The accessibility work was the most rewarding part. Making the country selector work with VoiceOver and NVDA required testing with actual screen readers, not just checking ARIA attributes. There’s a big gap between “technically accessible” and “actually usable.”

Check out the project on GitHub.