bigbullui logobigbullui
Componentpickers

Masked Input

Formatted code field with auto-formatted delimiters and validation mark.

Preview

Live Preview
TICKET SERIAL CODE
Auto-formats characters with ticket validation

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add masked-input

Or install the full npm package:

npm install bigbullui

Usage

import { MaskedInput } from "@/components/ui/masked-input";

<MaskedInput mask="BB-####-####" defaultValue="BB-2026-0001" />

Source code

masked-input.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface MaskedInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
  mask?: string; // e.g. "BB-####-####" where # represents digits, A represents alpha
  value?: string;
  defaultValue?: string;
  onValueChange?: (val: string, complete: boolean) => void;
  className?: string;
}

export function MaskedInput({
  mask = "BB-####-####",
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  placeholder,
  className,
  ...props
}: MaskedInputProps) {
  const [internalVal, setInternalVal] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const currentVal = isControlled ? controlledValue : internalVal;

  const applyMask = (raw: string) => {
    let rawIndex = 0;
    let result = "";
    const cleanChars = raw.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();

    for (let i = 0; i < mask.length; i++) {
      if (rawIndex >= cleanChars.length) break;

      const maskChar = mask[i];
      if (maskChar === "#") {
        while (rawIndex < cleanChars.length && !/[0-9]/.test(cleanChars[rawIndex])) {
          rawIndex++;
        }
        if (rawIndex < cleanChars.length) {
          result += cleanChars[rawIndex];
          rawIndex++;
        }
      } else if (maskChar === "A") {
        while (rawIndex < cleanChars.length && !/[a-zA-Z]/.test(cleanChars[rawIndex])) {
          rawIndex++;
        }
        if (rawIndex < cleanChars.length) {
          result += cleanChars[rawIndex];
          rawIndex++;
        }
      } else {
        result += maskChar;
        if (cleanChars[rawIndex] === maskChar) {
          rawIndex++;
        }
      }
    }
    return result;
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const formatted = applyMask(e.target.value);
    const isComplete = formatted.length === mask.length;
    if (!isControlled) {
      setInternalVal(formatted);
    }
    onValueChange?.(formatted, isComplete);
  };

  const isComplete = currentVal.length === mask.length;

  return (
    <div className={cn("relative inline-flex items-center font-mono select-none", className)}>
      <input
        type="text"
        value={currentVal}
        onChange={handleChange}
        placeholder={placeholder || mask}
        className={cn(
          "w-full rounded-md border-2 border-foreground bg-card px-3 py-2 text-xs font-bold uppercase tracking-wider text-foreground placeholder:text-muted-foreground/60 transition-all",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          isComplete ? "border-accent shadow-xs" : "hover:border-foreground/70"
        )}
        {...props}
      />
      {isComplete && (
        <span
          className="absolute right-2.5 flex size-4 items-center justify-center rounded-xs bg-accent text-[10px] font-bold text-accent-foreground animate-[scale-in_0.15s_ease-out]"
          title="Ticket Serial Validated"
        >
          ✓
        </span>
      )}
    </div>
  );
}

Props

PropTypeDescription
maskstringPattern format string (# for digits, A for alpha).
valuestringControlled text value.
onValueChange(val: string, complete: boolean) => voidChange callback with completion state.