Skip to content
txbrendan/ui

Card Field

beta

Card 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

Card

Try 4242 4242 4242 4242, or 4000 0000 0000 0002 to see a decline.

brands

Visa
Visa
Mastercard
Mastercard
Amex
Amex

states

Expired
Visa
Mistyped
Visa
Declined
Visa
Other problem
Visa
Disabled
Visa

size

Card, sm
Visa
Card, md
Visa
Card, lg
Visa

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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"
/>