EmpireUI
Get Pro
← Blog8 min read#coupon#discount code#react

Coupon Code Input in React: Validate, Success State, Shake Error

Build a coupon code input in React with real validation, animated success states, and a CSS shake on error — no library needed.

Code editor showing a React coupon input component with validation

Why Coupon Inputs Usually Suck

Most ecommerce coupon fields are an afterthought — a plain text input, a button, and maybe a red paragraph that says "Invalid code" with zero personality. Users type SUMMER20, hit apply, and get a generic error message that doesn't even tell them why it failed. Then they wonder if they typed it wrong, or if the code expired, or if the site is just broken.

The thing is, a coupon input is a high-stakes UI moment. Someone found a discount code — on Instagram, in an email, from a friend — and they're excited to use it. If your input fumbles that moment with a janky experience, you've added friction right before checkout. That's the last place you want friction.

In practice, a well-built coupon field needs three things: a debounced or submit-triggered validation call, a clear success state (green border, checkmark, discount amount shown), and a shake animation on error so the user immediately knows the code didn't work. Let's build all three from scratch — no animation library, no form library overhead, just React 18 and CSS.

One more thing — if you're already using Empire UI, the box shadow generator is great for dialing in the focus/success/error shadow variants you'll want for the input field.

The Component API You Actually Want

Before writing a single line, think about what this component needs to expose. You want a controlled input with an onApply callback that returns a promise — async validation is the real-world case. The parent component owns the discount state and passes down whatever feedback it gets from the API.

Here's the interface we're targeting: ``tsx <CouponInput onApply={async (code) => { const result = await applyDiscountCode(code); return result; // { valid: boolean; discount?: string; message?: string } }} placeholder="Enter promo code" disabled={false} /> ` Keeping onApply` as an async callback means the component stays generic. It doesn't care whether you're hitting a Stripe API, a Shopify endpoint, or a local array of valid codes. The parent decides.

Worth noting: don't make value a controlled prop here. Coupon inputs are self-contained — the user types a code, hits apply, and the component owns that string until the discount is confirmed. If you need to pre-fill a code (from a URL param, say), you can expose an initialValue prop instead.

The state inside the component comes down to four things: the current string value, a loading boolean, a status ('idle' | 'success' | 'error'), and a message string. That's it. You don't need a form library for this.

Building the Base Component

Let's write the full component. We'll use useRef for triggering the shake animation and useState for everything else. The shake is a CSS class we add and then remove after 600ms — classic approach, works every time. ``tsx import { useState, useRef, useCallback } from 'react'; import './CouponInput.css'; type ApplyResult = { valid: boolean; discount?: string; message?: string; }; type CouponInputProps = { onApply: (code: string) => Promise<ApplyResult>; placeholder?: string; disabled?: boolean; initialValue?: string; }; export function CouponInput({ onApply, placeholder = 'Promo code', disabled = false, initialValue = '', }: CouponInputProps) { const [value, setValue] = useState(initialValue); const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [message, setMessage] = useState(''); const [isShaking, setIsShaking] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const triggerShake = useCallback(() => { setIsShaking(true); setTimeout(() => setIsShaking(false), 600); }, []); const handleApply = async () => { if (!value.trim() || status === 'loading') return; setStatus('loading'); setMessage(''); try { const result = await onApply(value.trim().toUpperCase()); if (result.valid) { setStatus('success'); setMessage(result.discount ?? 'Discount applied!'); } else { setStatus('error'); setMessage(result.message ?? 'Invalid code. Try another.'); triggerShake(); inputRef.current?.focus(); } } catch { setStatus('error'); setMessage('Something went wrong. Try again.'); triggerShake(); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') handleApply(); if (e.key === 'Escape') { setValue(''); setStatus('idle'); setMessage(''); } }; const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setValue(e.target.value); if (status !== 'idle') { setStatus('idle'); setMessage(''); } }; return ( <div className="coupon-wrapper"> <div className={[ 'coupon-field', coupon-field--${status}, isShaking ? 'coupon-field--shake' : '', ] .filter(Boolean) .join(' ')} > <input ref={inputRef} type="text" value={value} onChange={handleChange} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={disabled || status === 'success'} autoCapitalize="characters" autoCorrect="off" spellCheck={false} aria-label="Coupon code" aria-describedby="coupon-message" /> <button onClick={handleApply} disabled={!value.trim() || status === 'loading' || status === 'success'} > {status === 'loading' ? '...' : status === 'success' ? '✓' : 'Apply'} </button> </div> {message && ( <p id="coupon-message" className={coupon-message coupon-message--${status}} role={status === 'error' ? 'alert' : 'status'} > {message} </p> )} </div> ); } ``

A few things worth calling out. The toUpperCase() in handleApply is deliberate — promo codes are almost always uppercase, and normalizing on submit means you don't force users to care about case. The autoCapitalize="characters" attribute does this visually on mobile keyboards as a bonus.

The aria-describedby linking the message paragraph to the input is not optional if you care about screen readers. When the error message appears, a user on NVDA or VoiceOver needs that association to hear the validation result without hunting for it.

Escape to clear is a small touch that experienced users will love. It's the kind of thing that doesn't show up in any spec but you notice immediately when it's missing.

The CSS: Border Transitions, Shake Keyframe, Status Colors

The CSS is doing real work here. We need a smooth border-color transition between idle/error/success states, a keyframe shake that actually feels like a physical rejection (not just wiggling), and a loading shimmer on the button. All within about 60 lines. ``css .coupon-wrapper { display: flex; flex-direction: column; gap: 8px; max-width: 400px; } .coupon-field { display: flex; border: 2px solid #d1d5db; border-radius: 8px; overflow: hidden; transition: border-color 200ms ease, box-shadow 200ms ease; } .coupon-field:focus-within { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); } .coupon-field--success { border-color: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); } .coupon-field--error { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); } .coupon-field input { flex: 1; padding: 12px 14px; border: none; outline: none; font-size: 14px; font-family: 'JetBrains Mono', monospace; letter-spacing: 0.05em; background: transparent; } .coupon-field input:disabled { opacity: 0.6; cursor: not-allowed; } .coupon-field button { padding: 0 20px; background: #111; color: #fff; border: none; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 150ms ease, opacity 150ms ease; white-space: nowrap; } .coupon-field button:hover:not(:disabled) { background: #333; } .coupon-field button:disabled { opacity: 0.5; cursor: not-allowed; } .coupon-message { font-size: 13px; margin: 0; } .coupon-message--success { color: #16a34a; } .coupon-message--error { color: #dc2626; } /* The shake — 3 snappy back-and-forth moves */ @keyframes shake { 0% { transform: translateX(0); } 15% { transform: translateX(-6px); } 30% { transform: translateX(6px); } 45% { transform: translateX(-5px); } 60% { transform: translateX(5px); } 75% { transform: translateX(-3px); } 90% { transform: translateX(3px); } 100%{ transform: translateX(0); } } .coupon-field--shake { animation: shake 0.6s ease-in-out; } ``

The monospace font on the input (JetBrains Mono) is a deliberate stylistic choice — coupon codes look like codes, not words, and a monospace font reinforces that. If you'd rather not pull in a font, font-family: monospace still works fine.

The shake keyframe uses six keyframes across 600ms. That number matters — shorter feels twitchy, longer feels sluggish. The 6px max displacement at 15% snaps the user's eye without being violent about it. Honestly, most shake animations I've seen in production go too far with 10-12px and end up looking cartoonish.

Quick aside: the 3px box-shadow spread on focus/error/success states mimics what Stripe and Shopify Checkout do with their inputs. It's subtle — just enough to signal state change without shouting. If you want to go further with glassmorphism-style shadows, the glassmorphism generator gives you a live preview you can copy straight into your CSS.

Wiring Up Real Validation

In a real app, onApply hits your backend. Here's how that looks with a Next.js API route: ``ts // app/api/coupon/route.ts import { NextRequest, NextResponse } from 'next/server'; const VALID_CODES: Record<string, { discount: string; expiry: string }> = { SUMMER20: { discount: '20% off', expiry: '2026-12-31' }, WELCOME10: { discount: '10% off your first order', expiry: '2027-01-01' }, }; export async function POST(req: NextRequest) { const { code } = await req.json(); if (!code || typeof code !== 'string') { return NextResponse.json( { valid: false, message: 'No code provided.' }, { status: 400 } ); } const entry = VALID_CODES[code.toUpperCase()]; if (!entry) { return NextResponse.json({ valid: false, message: 'Code not found.' }); } const now = new Date(); if (new Date(entry.expiry) < now) { return NextResponse.json({ valid: false, message: 'This code has expired.' }); } return NextResponse.json({ valid: true, discount: entry.discount }); } ` And the client-side call: `ts const applyCode = async (code: string) => { const res = await fetch('/api/coupon', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); return res.json(); }; // In your checkout page: <CouponInput onApply={applyCode} /> ``

The expiry check matters more than most developers realize. I've seen production stores where expired codes still worked because the validation only checked if the code existed, not whether it was still active. That's a quiet revenue leak — users share old codes for months after a campaign ends.

Worth noting: if you're using Stripe, you'd swap the API route body for a stripe.promotionCodes.list({ code }) call and check restrictions.expires_at. The component doesn't care — as long as your onApply returns the right shape, you're good.

For Shopify specifically, you'd hit their GraphQL Admin API with a discountCodeBasicByCode query or use their REST endpoint. Either way the component is completely decoupled from the backend implementation. That's the whole point of the callback design.

Success State: Showing the Discount and Allowing Removal

Once a code is valid, users need to see what they got and have a way to remove it. A locked input with a checkmark is fine, but showing the actual discount value — "20% off applied" — next to a small "Remove" link is better. Here's how to extend the component to support removal: ``tsx // Add this inside CouponInput, after the message paragraph: {status === 'success' && ( <button className="coupon-remove" onClick={() => { setValue(''); setStatus('idle'); setMessage(''); onRemove?.(); }} > Remove </button> )} ` And add onRemove?: () => void` to the props. This lets the parent update the cart total when the user decides to ditch the code.

The success message text should be specific. "Discount applied" is fine. "$15 off applied" or "20% off applied" is better. Pass that specificity through the discount field in your API response and display it directly. Users are anxious at checkout — confirming the exact value removes doubt.

Look, one failure mode I see constantly: developers lock the input on success but don't provide a removal path. The user applies the wrong code (maybe a friend's referral link), realizes it, and now they're stuck. Either refresh the page or find the hidden "remove" button that doesn't exist. Design the removal path from day one.

If you want to style the success state with a green gradient shimmer or a more polished visual treatment, check out Empire UI's glassmorphism components — the card and input variants there translate well to this use case.

Accessibility and Edge Cases You Can't Skip

There are a few edge cases that will bite you in production. First: rate limiting. If you're making API calls on every apply click, a user hammering the button can rack up server calls. Add a short debounce on the button click — or just disable the button during the loading state, which we already do. That's usually enough.

Second: the code clearing behavior on change. When a user edits the input after a failed validation, you should reset back to idle. We handle this in handleChange — setting status to idle so the red border disappears as soon as they start correcting. Not doing this means the input stays red while they type, which is hostile UX.

Third: paste handling. Users copy-paste promo codes constantly. Make sure your handleChange trims whitespace — copy-pasting from some email clients adds a trailing space and breaks your exact-match validation. The value.trim() in handleApply catches this, but also consider e.target.value.trim() in handleChange so the displayed value is clean. ``tsx const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { // Trim immediately — catches paste with leading/trailing spaces setValue(e.target.value.replace(/\s/g, '')); if (status !== 'idle') { setStatus('idle'); setMessage(''); } }; ``

On the accessibility side: we already have aria-describedby and role="alert" on the error message. One more thing — make sure the button label changes are announced. When the button reads "✓" in the success state, a screen reader will say "checkmark" which isn't great. Prefer aria-label="Code applied" on the button in that state, or keep the text label and hide the icon visually.

Testing this is straightforward. Drop the component into a Storybook story with three knobs: onApply that resolves success, onApply that resolves error, and onApply that rejects with a network error. Cover those three cases and you've covered 95% of what users will actually hit. You can browse more component patterns on Empire UI's component library for inspiration on how to structure the surrounding checkout form.

FAQ

Should I validate the coupon on input change or on submit?

On submit. Validating on change means an API call for every keystroke, which hammers your server and shows errors before the user has even finished typing. Trigger validation only when the user clicks Apply or hits Enter.

How do I prevent users from applying the same code twice?

Track applied codes in your cart state. On the server side, also check whether a promotion has already been applied to the session or order before returning a success response. Client-side checks alone aren't enough.

Can I use this component with React Hook Form or Formik?

Yes. Since the component owns its own string value internally, you just need to call your form library's setValue in the onApply callback when a code is successfully applied. No need to register the input itself with the form.

The shake animation isn't working in Safari. What's wrong?

Make sure you're not also setting transform elsewhere on the element via a CSS transition — Safari can conflict those. Add a will-change: transform on the .coupon-field class to give the browser a heads-up, and check that your animation shorthand includes the easing value explicitly.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

OTP Input in React: 6-Digit Code Entry With Auto-Focus and PasteNewsletter Signup in React: Form, Success State, Email ValidationE-Commerce Checkout UI: Step-by-Step, Trust Signals, Error StatesWishlist UI Design: Heart Toggle, Count Badge, Empty State