Credit Card Form in React: Flip Animation, Input Mask, Validation
Build a polished credit card form in React with a 3D flip animation, real-time input masking, and client-side validation — no library bloat required.
Why Build Your Own Instead of Grabbing a Package
Most payment UI tutorials point you at a library that wraps Stripe Elements or throws a 40 kB dependency at a 300-line problem. That's fine for Stripe's hosted fields — but if you want full control over styling, animation timing, and validation messages that match your design system, you build it yourself.
Honestly, the flip animation people want on credit card forms isn't rocket science. It's three CSS properties and one state boolean. The input mask for the card number is maybe 10 lines of logic. Once you see the pieces, you'll wonder why you were reaching for react-credit-cards-2 in the first place.
That said, security caveats first: never store raw card numbers in state longer than needed for display, never send them to your own backend — that's what Stripe.js or Braintree tokenization is for. What we're building here is the *visual* layer. Pair it with your payment provider's tokenization hook and you get the best of both worlds: a beautiful, controlled UI that stays PCI-compliant.
Worth noting: this approach works in React 18 and 19. The patterns below don't use any APIs that changed between those versions, so you won't hit surprise deprecations.
Project Setup and Component Structure
Start with a clean structure. You want the card visual and the form inputs as siblings inside a wrapper — the card is purely presentational and the form drives all the state.
npx create-next-app@latest card-form-demo --typescript --tailwind
cd card-form-demoThen inside src/components/ create three files: CreditCardForm.tsx (the parent), CardVisual.tsx (the 3D card), and useCardForm.ts (the logic hook). Keeping the hook separate means you can swap the visual design later without touching validation logic — something you'll thank yourself for in six months.
// src/components/CreditCardForm.tsx
import { CardVisual } from './CardVisual'
import { useCardForm } from './useCardForm'
export function CreditCardForm() {
const form = useCardForm()
return (
<div className="flex flex-col gap-8 md:flex-row">
<CardVisual {...form.visual} />
<form onSubmit={form.handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
{/* inputs go here */}
</form>
</div>
)
}One more thing — the card visual needs perspective set on its parent to get a real 3D flip. A value of 1000px looks natural at typical card widths around 340px. Go below 600px and it starts looking like a cheap CSS trick.
The 3D Flip Animation
The flip happens when focus moves to the CVV field. Three CSS properties do all the work: perspective on the wrapper, transform-style: preserve-3d on the card, and rotateY(180deg) on the flipped state. The front and back faces both need backface-visibility: hidden.
// src/components/CardVisual.tsx
interface CardVisualProps {
number: string
name: string
expiry: string
cvv: string
flipped: boolean
}
export function CardVisual({ number, name, expiry, cvv, flipped }: CardVisualProps) {
return (
<div className="perspective-[1000px] w-[340px] h-[210px]">
<div
className={`relative w-full h-full transition-transform duration-700 ease-in-out
[transform-style:preserve-3d] ${
flipped ? '[transform:rotateY(180deg)]' : ''
}`}
>
{/* FRONT */}
<div className="absolute inset-0 [backface-visibility:hidden] rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-800 p-6 text-white shadow-2xl">
<div className="text-lg tracking-widest mt-8">{number || '•••• •••• •••• ••••'}</div>
<div className="flex justify-between mt-4 text-sm">
<span>{name || 'FULL NAME'}</span>
<span>{expiry || 'MM/YY'}</span>
</div>
</div>
{/* BACK */}
<div className="absolute inset-0 [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl bg-gradient-to-br from-slate-700 to-slate-900 shadow-2xl">
<div className="mt-8 h-10 bg-black/40 w-full" />
<div className="px-6 mt-4 flex justify-end">
<div className="bg-white text-slate-900 text-sm px-3 py-1 rounded font-mono">
{cvv || '•••'}
</div>
</div>
</div>
</div>
</div>
)
}The duration-700 gives you a 700ms transition — fast enough to feel snappy, slow enough to actually register as a card flip. Go to 300ms and it reads as a glitch. Go to 1000ms and users wonder if something broke. In practice, 650–750ms is the sweet spot for this specific animation.
Quick aside: if you're using Tailwind v3 you'll need to add perspective as a custom utility in your config. Tailwind v4 (released in early 2025) includes it natively. Check your version before adding custom config you don't need.
Input Masking Without a Library
Card number masking is the part people overthink. All you're doing is inserting a space every 4 digits and capping input at 19 characters (16 digits + 3 spaces). Handle it in a single onChange with string manipulation — no react-input-mask, no imask.
// inside useCardForm.ts
function maskCardNumber(raw: string) {
// strip non-digits, then group into chunks of 4
return raw
.replace(/\D/g, '')
.slice(0, 16)
.replace(/(\d{4})/g, '$1 ')
.trim()
}
function maskExpiry(raw: string) {
const digits = raw.replace(/\D/g, '').slice(0, 4)
if (digits.length >= 3) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`
}
return digits
}There's one gotcha with the expiry mask: when the user hits backspace after the slash (e.g., from 12/2 to 12/), you need to handle the deletion yourself or it'll feel sticky. Detect whether the raw value ends with / and strip it: if (raw.endsWith('/')) return raw.slice(0, -1). That's it.
Amex uses a 4-6-5 grouping rather than 4-4-4-4, and its CVV is 4 digits not 3. You can detect Amex by checking if the number starts with 34 or 37 — worth handling if your product actually accepts Amex cards.
function detectNetwork(number: string): 'amex' | 'visa' | 'mastercard' | 'unknown' {
const raw = number.replace(/\s/g, '')
if (/^3[47]/.test(raw)) return 'amex'
if (/^4/.test(raw)) return 'visa'
if (/^5[1-5]|^2[2-7]/.test(raw)) return 'mastercard'
return 'unknown'
}Validation with useCardForm Hook
Keep validation in the hook, co-located with state. You don't need Zod or React Hook Form for four fields — a plain object of error strings is enough, and you'll ship faster.
// src/components/useCardForm.ts
import { useState } from 'react'
interface FormState {
number: string
name: string
expiry: string
cvv: string
flipped: boolean
}
interface FormErrors {
number?: string
name?: string
expiry?: string
cvv?: string
}
export function useCardForm() {
const [form, setForm] = useState<FormState>({
number: '', name: '', expiry: '', cvv: '', flipped: false,
})
const [errors, setErrors] = useState<FormErrors>({})
function validate(): boolean {
const errs: FormErrors = {}
const rawNumber = form.number.replace(/\s/g, '')
if (rawNumber.length < 15) errs.number = 'Enter a valid card number'
if (!form.name.trim()) errs.name = 'Cardholder name required'
const [mm, yy] = form.expiry.split('/')
const month = parseInt(mm, 10)
const year = 2000 + parseInt(yy || '0', 10)
const now = new Date()
if (!mm || !yy || month < 1 || month > 12 || year < now.getFullYear() ||
(year === now.getFullYear() && month < now.getMonth() + 1)) {
errs.expiry = 'Invalid or expired date'
}
if (form.cvv.length < 3) errs.cvv = 'CVV must be 3 or 4 digits'
setErrors(errs)
return Object.keys(errs).length === 0
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (validate()) {
// hand off to your payment provider here
console.log('valid — tokenize now')
}
}
return { form, setForm, errors, handleSubmit, flipped: form.flipped }
}The expiry validation is the one people get wrong. You need to compare against the *current month*, not just the year — a card that expired in January 2026 is still expired even though 2026 isn't over. The check above does this correctly by comparing both year and month.
Look, inline error messages beat summary errors for payment forms every time. Show the error directly under the field that's wrong, keep the copy short ('Invalid or expired date' not 'Please enter a valid expiry date in MM/YY format'), and clear the error as soon as the user starts typing in that field again. That's the pattern that converts.
// clear error on input change
function handleChange(field: keyof FormState, value: string) {
setForm(prev => ({ ...prev, [field]: value }))
if (errors[field as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [field]: undefined }))
}
}Styling and Visual Polish
The gradient on the card face does a lot of work. A flat color card looks like a mockup; a gradient looks like a real card. Use a subtle radial highlight to fake the glossy plastic feel — radial-gradient(circle at 30% 20%, rgba(255,255,255,0.15), transparent 60%) layered over your base gradient.
Card numbers should render in a monospace font with wide letter spacing. tracking-[0.2em] in Tailwind, or letter-spacing: 0.2em in plain CSS. Use font-variant-numeric: tabular-nums so the digits don't shift width as you type. These are small details but they're the difference between a form that feels premium and one that feels assembled from Stack Overflow snippets.
If you're pulling design tokens from Empire UI, the glassmorphism card treatment works particularly well for dark backgrounds. Take a look at the glassmorphism components — the frosted-glass effect on the card front looks sharp, and the glassmorphism generator can generate the exact backdrop-filter values you need in seconds.
For the form inputs themselves, a 48px minimum height keeps them touch-friendly on mobile without looking oversized on desktop. Add 1px solid borders in your neutral color scale, round them to 8px, and give focused inputs a 2px box shadow in your brand color. The box shadow generator is handy here if you want to tweak the focus ring without guessing values.
One more thing — disable autocomplete on the CVV field specifically. autoComplete="off" on the CVV input prevents browsers from suggesting values for a field that should never be saved. The card number and expiry are fine to autocomplete; CVV should always be manually entered.
Accessibility and Mobile Considerations
Payment forms are high-stakes interactions. A user who can't complete checkout because of an inaccessible form is a lost sale, and it's avoidable. Start with proper <label> elements wired via htmlFor — not placeholder text as a substitute, and not aria-label as a last resort. Real labels.
Every input needs an aria-describedby pointing to its error message element, and that element needs role="alert" so screen readers announce it when it appears. The flip animation should be wrapped in aria-hidden="true" — it's decorative. Screen reader users don't need to hear about a card flipping; they need to fill in their CVV.
Mobile keyboards matter too. Use inputMode="numeric" on the card number, expiry, and CVV fields — this gives iOS and Android users the numeric keypad without restricting the field to only digits (which you need to allow for the / in expiry). Don't use type="number" here; it breaks the mask formatting on some browsers.
What about autofill? Chrome and Safari have heuristics for detecting card forms. Help them out by using the correct autoComplete attribute values: cc-number for the card number, cc-name for cardholder name, cc-exp for expiry, and cc-csc for the CVV. Autofill working correctly on payment forms dramatically increases mobile conversion — this is one of those details that moves metrics.
Whether you use this as a standalone component or integrate it into a larger checkout page, the same principles apply. Check out the templates section if you want a full checkout page scaffold to drop this into — saves you the layout work.
Wiring Up to a Real Payment Provider
Everything above is the UI layer. The actual card data should never hit your server as plaintext. The pattern is: render your custom UI, collect the values, then pass them to your payment provider's tokenization method right in the handleSubmit.
With Stripe, you'd create a PaymentMethod client-side using Stripe.js and hand the token to your backend. Your handleSubmit becomes roughly this:
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!validate()) return
const { paymentMethod, error } = await stripe.createPaymentMethod({
type: 'card',
card: elements.getElement(CardNumberElement)!, // Stripe's own element
billing_details: { name: form.name },
})
if (error) {
setErrors({ number: error.message })
return
}
// send paymentMethod.id to your backend
await fetch('/api/pay', {
method: 'POST',
body: JSON.stringify({ paymentMethodId: paymentMethod.id }),
})
}In practice, if you're on Stripe, you might use Stripe Elements for the actual input capture (hidden inputs that Stripe controls) and use your custom UI for everything *else* — the card visual, the flip animation, the branding. Stripe's appearance API as of version 3.x lets you style their embedded elements to match your design system closely enough that users won't notice the seam.
The flip animation and masking we built are fully compatible with that hybrid approach. Your CardVisual component reads from local state that mirrors what Stripe Elements captures; the flip trigger is still just CVV focus. Users get the polished animation; your PCI scope stays minimal. That's the architecture worth building toward.
FAQ
Yes, but you need -webkit-backface-visibility: hidden alongside the standard property for Safari 15 and earlier. Safari 16+ handles the unprefixed version fine. If you're using Tailwind's JIT, add both in your arbitrary value syntax or use a global CSS reset.
Absolutely — it's the recommended approach for PCI compliance. Build the card visual and flip animation as shown, but replace your card number/expiry/CVV inputs with Stripe's mounted elements. Use Stripe's onChange events to update your visual state for the live card preview.
Detect the network from the first 2 digits — 34 or 37 means Amex. Then swap your masking function to produce 4-6-5 chunks and set CVV length to 4. Run the detection on every keystroke so the mask updates as soon as the user types enough digits to identify the network.
Yes, it's worth adding — a Luhn check catches typos before you fire any API calls. It's about 10 lines of code and runs entirely client-side. Search for 'luhn algorithm javascript' and you'll find clean implementations you can drop straight into your validation function without any dependency.