Coupon input React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React coupon code input for carts and checkouts: a link that opens it, Apply inside the box, async checks and applied codes as chips.
Tap or click "Have a promo code?" and the field slides open with the cursor in it (the chevron turns). Type a code in any case with any spaces; it shows in capitals. Press Enter or Apply: with a server check the button spins while it waits, and Enter never submits the checkout form around it. A good code lands as a chip with a check that pops, the code, its label and what it saves; at the code limit the field folds away until you remove a chip. A bad code shakes the box and says what to do ("Add $45.20 more to use SAVE50", "This code expired on August 31, 2026"). Escape clears the typed code; a second Escape on an empty field closes it and puts focus back on the link. Escape while a code is being checked does nothing. Tab reaches the remove button of each chip; Enter, Space, Backspace or Delete removes it. When the cart changes, catalogue codes recount what they save and a code that no longer applies turns amber with a note under it. Every outcome is read out once by screen readers; on a phone the outcome also buzzes. Under reduced motion everything fades instead of moving.
Coupon input is a Pro React component in the React commerce components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add coupon-input, together with Button, Field, Spinner, Tag, Commerce and 6 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add coupon-inputCoupon input is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import CouponInput from @/components/ui/coupon-input.
import { CouponInput, type AppliedCoupon } from "@/components/ui/coupon-input"
import type { Coupon } from "@/lib/commerce"
import { useState } from "react"
const COUPONS: Coupon[] = [
{ code: "FALL15", type: "percent", value: 15, maxDiscount: 150, label: "Fall sale" },
{ code: "FREESHIP", type: "shipping", value: 0, label: "Free delivery" },
{ code: "SAVE50", type: "fixed", value: 50, minSubtotal: 300 },
]
export function CartCoupon() {
const [applied, setApplied] = useState<AppliedCoupon[]>([])
return (
<CouponInput
value={applied}
onValueChange={setApplied}
coupons={COUPONS}
subtotal={489.7}
shipping={19.99}
locale="en-US"
labels={{
trigger: "Got a discount code?",
invalid: "We don't recognize that code",
minSubtotal: (missing, code) => `Add ${missing} more to use ${code}`,
}}
/>
)
}Props
subtotaldefault0numberThe items' subtotal, used for minimum-order checks and to work out what a percent or fixed code saves. The demo uses its $489.70 cart while this is 0.
shippingdefault0numberThe delivery cost a free-delivery code saves. The demo uses $19.99 (UPS) while this is 0.
currencydefault"USD"stringISO 4217 code for the saved amounts and the minimum-order message: "$", "€", "lei".
localedefault""stringIntl locale for the amounts ("en-US" prints $45.20, "ro-RO" 45,20 lei). Empty uses the UIProvider locale, then the browser's.
labeldefault""ReactNodeA visible label above the box, for checkout forms. Without it the input is named by the placeholder text.
sizedefault"md""sm" | "md" | "lg"The field box at 32 / 40 / 48px (44px on touch at md), with a matching Apply button, chips and link text.
variantdefault"soft""soft" | "outline"soft is the filled well that works on any surface; outline is a hairline box for white forms.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape of the box and the Apply button; scales with the size like the button's.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The color of the soft Apply button. brand suits a checkout's one orange accent.
defaultOpendefaultfalsebooleanStarts with the field open instead of the link. Only matters while collapsible.
disableddefaultfalsebooleanDims and blocks the link, the field, Apply and the chips' remove buttons.
collapsibledefaulttruebooleanStarts as the "Have a promo code?" link that opens the field. Off keeps the field open (checkout pages).
maxCouponsdefault1numberHow many codes can be applied together. At the limit the field hides until a chip is removed.
hapticsdefaulttruebooleanA success or error buzz on touch devices when a check ends (lib/haptics). Never on a mouse click.
valueAppliedCoupon[]The applied codes, controlled. AppliedCoupon = { code: string; label?: ReactNode; amount?: number; type?: CouponType; valid?: boolean }.
defaultValuedefault"[]"AppliedCoupon[]The applied codes to start with, uncontrolled.
onValueChange(value: AppliedCoupon[]) => voidCalled with the new list after a code is applied or removed.
onApply((code: string) => CouponResult | Promise<CouponResult>)The server check, called with the trimmed upper-case code. Resolve the exported CouponResult, { ok: true, coupon } or { ok: false, error }; a rejected promise shows its Error.message or labels.invalid. Wins over coupons.
couponsCoupon[]A local catalogue (lib/commerce) checked case-insensitively with couponDiscount against subtotal when onApply is absent. Applied catalogue codes are also rechecked whenever subtotal or shipping change.
openbooleanWhether the field is open, controlled. Pair with onOpenChange.
onOpenChange(open: boolean) => voidCalled when the link, Escape or reaching the code limit opens or closes the field.
iconReactNodeThe icon on the link and at the start of the box; a TicketPercent by default, null hides it.
labelsPartial<CouponInputLabels>Every string: trigger "Have a promo code?", placeholder "Promo code", apply "Apply", checking "Checking…", remove(code), applied(code), saved(amount) "-$45", freeShipping "Free delivery", invalid, minSubtotal(missing, code), duplicate, limit "One code per order", empty "Enter a code", noLongerValid(code), removed(code), list "Applied codes" (names the chip list for screen readers). The English set is exported as DEFAULT_COUPON_INPUT_LABELS.
classNamesPartial<Record<"trigger" | "label" | "box" | "input" | "apply" | "error" | "list" | "chip" | "warning", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root, merged last.
Parts
DEFAULT_COUPON_INPUT_LABELS
constantThe English strings: trigger ("Have a promo code?"), placeholder, apply, checking, remove(code), applied(code), saved(amount), freeShipping, the error messages (invalid, minSubtotal, duplicate, limit, empty) and noLongerValid(code). Spread it into labels to translate a few. Type: CouponInputLabels.
AppliedCoupon
typeA code that is applied: { code, label?, amount?, type?, valid? }. amount is what it takes off in major units; valid: false marks a code that no longer applies.
CouponResult
typeWhat onApply returns or resolves to: { ok: true, coupon } or { ok: false, error }, where error is the message shown under the box.
Examples
Cart footer
Collapsed to a link in a narrow cart column; opens on tap, checks a local catalogue.
Checkout
Always open with a visible label, an outline box and a brand Apply, checked by a server.
Two codes
maxCoupons={2}: a percent code and free delivery together; the field folds away at the limit.
Minimum order
SAVE50 needs $300: the error says what is missing; add a second case and it applies.
Server errors
onApply rejects expired and first-order codes; the box shakes and the message says why.
No longer valid
Remove the laptop stand after SAVE50 and the chip warns; add it back and it applies again.
Sizes
sm, md and lg boxes with their Apply buttons and chips.
Dependencies
Files
- components/ui/coupon-input.tsx
FAQ
Is the React Coupon input component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Coupon input?
Run npx wingo-ui@latest add coupon-input. It also adds the Wingo UI items it builds on (Button, Field, Spinner, Tag, Commerce and 6 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/coupon-input.
How does Coupon input behave on phones?
Every outcome is read out once by screen readers; on a phone the outcome also buzzes.
See also
Used in
More React commerce components
Articles using this component
- React Checkout Page: Cart, Steps, Coupons and a Pay Bar
Build it: screens and apps12 min read