VisorVisor
ComponentsForm

TIN Input

A write-only, masked 9-digit TIN/EIN input with a last-four echo that never re-displays the value.

A taxpayer ID is typed once and never shown back. TinInput masks every character as it is typed, and on blur a complete entry shows only the grouping mask and the last four. The digits leave the component through one onValueChange call and nowhere else. The readouts under these demos show what that call receives. They never show the digits.

Social Security Number

Type or paste nine digits, then tab away. Focusing the field again clears it for re-entry and never reveals it.

9 digits

nothing yet

Employer Identification Number

kind="ein" groups the same nine digits as ##-#######.

9 digits

nothing yet

On File

When a TIN is already stored, pass its lastFour. The field renders a read-only echo and a Replace button that swaps in an empty field and moves focus to it. Pass only the last four digits: the component never needs more. Once the new TIN is saved, pass its lastFour and the echo comes back, with no remount needed.

nothing yet

Error

error renders through the Visor field convention: a FieldError bound by aria-describedby, with aria-invalid on the field. A paste of more than nine digits is refused with an error of its own rather than truncated.

9 digits

nothing yet

Touch Size

size="lg" makes both the field and the Replace button at least 44px tall, which meets touch-target guidance on phone-first forms.

nothing yet

Disabled

9 digits

nothing yet

Installation

npx visor add tin-input

This copies two files into your project:

  • components/ui/tin-input/tin-input.tsx — the component
  • components/ui/tin-input/tin-input.module.css — the styles

This also installs input, button and field as dependencies.

Usage

import { Field, FieldLabel } from '@/components/ui/field/field';
import { TinInput } from '@/components/ui/tin-input/tin-input';

const [tin, setTin] = useState<string | null>(null);

<Field>
  <FieldLabel htmlFor="tin">Taxpayer ID</FieldLabel>
  <TinInput id="tin" kind="ssn" onValueChange={setTin} />
</Field>

TinInput is uncontrolled by design. There is no value prop, because a controlled value would have to be handed back in, and no name, because the digits are never serialized by a form. Send the digits you received from onValueChange in your own submit handler.

API Reference

TinInputProps

PropTypeDefaultDescription
kind*'ssn' | 'ein'Display grouping: SSN ###-##-#### or EIN ##-#######. Both are 9 digits.
onValueChange*(digits: string | null) => voidCalled with the 9 digits once the entry is complete, and with null while it is incomplete or cleared.
lastFourstringLast four digits of a TIN already on file. Renders a read-only "On file · ending 1234" with a Replace button. A new value brings the echo back after a Replace.
onReplace() => voidCalled when Replace swaps the on-file echo for an empty field.
errorstringError message, rendered as a FieldError bound by aria-describedby. Sets aria-invalid.
size'sm' | 'md' | 'lg''md'Size of the field and the Replace button. lg meets 44px touch targets.
disabledbooleanfalseDisables the field and the Replace button.
idstringId for the input, so a FieldLabel htmlFor associates the label.
aria-*aria-label | aria-labelledby | aria-describedby | aria-requiredForwarded to the input. aria-describedby is merged with the hint and error ids.
classNamestringAdditional CSS class names merged onto the wrapper.

How It Keeps the Digits Out

  • The value is only ever the mask. Every edit is intercepted before it reaches the DOM and applied to digits held in memory, so the digits are never in input.value or in any attribute, not even while typing.
  • Nothing is typed before hydration. The server-rendered field is read-only until the component mounts, so keystrokes on a slow first load are refused rather than shown in plain text.
  • On blur, the digits are dropped. When focus moves elsewhere on the page, a complete entry shows the grouping mask and the last four (•••-••-1234), and the component keeps only those four. Switching to another app and back leaves the entry as it was, so it is not wiped.
  • No way back out. There is no show/hide toggle, no copy affordance (copying the field copies bullets), no title or data-* carrying digits, and nothing is logged.
  • No save prompts. The field is a text input, never a password input, and it always carries the password-manager ignore attributes (data-1p-ignore, data-bwignore, data-lpignore, data-form-type="other"), even inside a form that allows them.
  • Input hygiene. inputMode="numeric", autoComplete="off", autoCorrect="off", autoCapitalize="off", spellCheck={false} and enterKeyHint="next". Non-digits are ignored and typing past nine is ignored. A paste strips spaces, dashes and any other non-digits, and one that would make more than nine digits is refused whole.
  • No pattern attribute. The value is always the mask, so pattern="[0-9]*" would keep the field invalid and block a native form submit. inputMode already raises the numeric keypad on current mobile browsers.

Accessibility

  • Associate the label through the Visor field row: FieldLabel htmlFor matching the id you pass.
  • The "9 digits" hint and any error are bound by aria-describedby, and an error sets aria-invalid.
  • After blur, the masked echo is described to assistive tech as "ending in 1234" rather than as a row of bullets.
  • The on-file echo is a read-only field, so it is reachable and announced with its label. Replace is a real button, reachable by keyboard with the Visor focus ring, and it moves focus into the new empty field.
  • The Replace button is type="button", so it never submits the surrounding form.