Card Field
betaCard details as one group: the number, the expiry (MM / YY), and the security code, with one line of help or error under it. The number groups itself as it is typed and names the brand in words.
Preview
pay
brands
states
size
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/card-field
Use it for
- Taking a card to pay for an order, a plan, or a booking.
- It is the look and the checks, for demos and prototypes. In production, use the payment provider's own hosted fields, so card numbers never reach your server.
- Set showErrors when Pay is pressed, rather than disabling Pay. Every problem then shows at once.
- When the bank says no, pass declined with why, like "Nothing was taken.", and onRetry. Clear it when the details change.
- Offer saved cards as selection cards above it, and this field for a new one.
Avoid it for
- Sending card numbers to your own server. Use the payment provider's hosted fields.
- Bank details or other numbers. Use an input.
- Showing a saved card. Show its brand and last four digits as text.
Props
- value
The parts as typed, like "4242 4242 4242 4242", "12 / 28", and "123". Pass it with onValueChange to control the field.
{ number: string; expiry: string; cvc: string }
- defaultValue
The parts to start with, when the field keeps its own value.
Partial<CardValue>
- onValueChange
Called on each change, with the brand, the last four digits, and whether every part is complete and right.
(value: CardValue, info: { brand: "Visa" | "Mastercard" | "Amex" | null; last4: string; complete: boolean }) => void
- label
Names the group, above it. Pressing it focuses the number. Without one, the group is named "Card details".
ReactNode
- hint
A short note under the group.
ReactNode
- error
Other problems, in red under the group, with red rings on every part.
ReactNode
- showErrors
Shows every problem, empty parts too. Set it when Pay is pressed.
boolean · default false
- declined
The bank declined it. "Declined" shows in red, then the string says why, in ink.
boolean | string
- onRetry
With declined: a retry icon ending the line, named "Try again". Return a promise, and it waits on it.
() => void | Promise<unknown>
- today
Today for the expiry check, as "2026-09-29", for demos and tests. Default: the browser's today, once the field is on the page.
string
- size
Height, padding, and text size of every part.
"sm" | "md" | "lg" · default "md"
- disabled
Turns every part off.
boolean · default false
- ref
Reaches the card number input, where focus starts.
Ref<HTMLInputElement>
- id
Lands on the group, with aria-* and data-*. Its aria-describedby joins the note's.
string
- className
Classes for the frame around the label, the group, and the note.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| value | { number: string; expiry: string; cvc: string } | - | The parts as typed, like "4242 4242 4242 4242", "12 / 28", and "123". Pass it with onValueChange to control the field. |
| defaultValue | Partial<CardValue> | - | The parts to start with, when the field keeps its own value. |
| onValueChange | (value: CardValue, info: { brand: "Visa" | "Mastercard" | "Amex" | null; last4: string; complete: boolean }) => void | - | Called on each change, with the brand, the last four digits, and whether every part is complete and right. |
| label | ReactNode | - | Names the group, above it. Pressing it focuses the number. Without one, the group is named "Card details". |
| hint | ReactNode | - | A short note under the group. |
| error | ReactNode | - | Other problems, in red under the group, with red rings on every part. |
| showErrors | boolean | false | Shows every problem, empty parts too. Set it when Pay is pressed. |
| declined | boolean | string | - | The bank declined it. "Declined" shows in red, then the string says why, in ink. |
| onRetry | () => void | Promise<unknown> | - | With declined: a retry icon ending the line, named "Try again". Return a promise, and it waits on it. |
| today | string | - | Today for the expiry check, as "2026-09-29", for demos and tests. Default: the browser's today, once the field is on the page. |
| size | "sm" | "md" | "lg" | "md" | Height, padding, and text size of every part. |
| disabled | boolean | false | Turns every part off. |
| ref | Ref<HTMLInputElement> | - | Reaches the card number input, where focus starts. |
| id | string | - | Lands on the group, with aria-* and data-*. Its aria-describedby joins the note's. |
| className | string | - | Classes for the frame around the label, the group, and the note. |
Accessibility
- The three parts sit in a group named by the label, or "Card details".
- Each part has its own name: "Card number", "Expiry date, MM / YY", and "Security code".
- The brand shows in words, never a logo, and describes the number input. It is said politely when it changes.
- A part with a problem is marked invalid and described by the note under the group, which is announced when it appears.
- Focus moves on once a part is complete and right. Backspace in an empty part goes back to the one before.
- The parts use the browser's card autofill and a number keypad, and take no name, so a plain form never posts the digits.
Examples
Start with a test card
<CardField
label="Card"
defaultValue={{ number: "4242 4242 4242 4242", expiry: "12 / 28", cvc: "123" }}
today="2026-09-29"
/>The bank declined it
<CardField
label="Card"
defaultValue={{ number: "4000 0000 0000 0002", expiry: "12 / 28", cvc: "123" }}
declined="Nothing was taken."
onRetry={() => {}}
today="2026-09-29"
/>