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-inputThis copies two files into your project:
components/ui/tin-input/tin-input.tsx— the componentcomponents/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
| Prop | Type | Default | Description |
|---|---|---|---|
kind* | 'ssn' | 'ein' | — | Display grouping: SSN ###-##-#### or EIN ##-#######. Both are 9 digits. |
onValueChange* | (digits: string | null) => void | — | Called with the 9 digits once the entry is complete, and with null while it is incomplete or cleared. |
lastFour | string | — | Last 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 | () => void | — | Called when Replace swaps the on-file echo for an empty field. |
error | string | — | Error 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. |
disabled | boolean | false | Disables the field and the Replace button. |
id | string | — | Id for the input, so a FieldLabel htmlFor associates the label. |
aria-* | aria-label | aria-labelledby | aria-describedby | aria-required | — | Forwarded to the input. aria-describedby is merged with the hint and error ids. |
className | string | — | Additional 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.valueor 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
titleordata-*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}andenterKeyHint="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
patternattribute. The value is always the mask, sopattern="[0-9]*"would keep the field invalid and block a native form submit.inputModealready raises the numeric keypad on current mobile browsers.
Accessibility
- Associate the label through the Visor field row:
FieldLabel htmlFormatching theidyou pass. - The "9 digits" hint and any error are bound by
aria-describedby, and an error setsaria-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.