Commerce & Checkout

Coupon Code Input

An ecommerce coupon input React component: on success the field turns into a perforated ticket chip with a tear-off stub animation and a rolling discount amount; on failure it shakes and shows the reason. The applied chip is removable and input is case-normalised.

The coupon field every checkout page needs. Input is auto-uppercased, whitespace-stripped and truncated to the length limit; pressing Apply or Enter hands the code to the onApply validator you provide, while the button shows a loading spinner and sets aria-busy. On success the input row switches to a ticket chip with a perforated left edge — a stub tears off and falls away, and the discount amount rolls in digit by digit; press the × on the right to remove it and type again. On failure the input shakes horizontally, turns red and shows the reason, ready for another try. Both the input value and the applied state support controlled and uncontrolled modes.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add coupon-code-input.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

20
Active edited usageDefault props
<CouponCodeInput/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/coupon-code-input.en.json

Or, once registries are configured in components.json, install it as @webberui/coupon-code-input.

Usage

import { CouponCodeInput } from "@/components/ui/coupon-code-input";

<CouponCodeInput
  label="Discount Code"
  currency="NT$"
  onApply={async (code) => {
    // Validate against your API; return ok plus the discount info
    if (code === "WELCOME100") {
      return { ok: true, discount: 100, label: "New Customer Gift" };
    }
    return { ok: false, reason: "No matchingDiscount Code" };
  }}
  onRemove={(code) => console.log(`removed ${code}`)}
/>

Props

PropTypeDefaultDescription
valuestring—Controlled input value; when omitted the component manages it internally (uncontrolled)
defaultValuestring""Initial input value in uncontrolled mode
onChange(value: string) => void—Callback when the input changes; the argument is the normalised (uppercased, whitespace-stripped) string
onApply(code: string) => Promise<CouponApplyResult> | CouponApplyResult—Validator invoked on Apply or Enter; when omitted every code is treated as a success
onRemove(code: string) => void—Callback when the applied coupon is removed; the argument is the removed code
appliedAppliedCoupon | null—Controlled applied state (null means none); when omitted the component manages it internally
currencystring"NT$"Currency prefix shown before the discount amount
placeholderstring"Enter Discount Code"Input placeholder text (default reads "enter a coupon code")
labelstring"Discount Code"Field label text (default reads "coupon code"); pass an empty string to hide it
maxLengthnumber20Maximum coupon length; longer input is truncated
disabledbooleanfalseDisable the input and buttons
classNamestring—Forwarded to the outermost container

AppliedCoupon

FieldTypeDefaultDescription
codestring—The applied coupon code (normalised)
discountnumber—Discount amount (positive); the amount and its rolling animation only show when provided
labelstring—Extra descriptive text (for example "new customer gift")

CouponApplyResult

FieldTypeDefaultDescription
okboolean—Whether the coupon applied successfully
discountnumber—Discount amount on success
labelstring—Extra descriptive text on success
reasonstring—Failure reason, shown in the error message and read out by screen readers

normalizeCouponCode(raw, maxLength) is also a named export, so the same normalisation logic can be reused at the form layer.

How it works

  • Ticket visuals: the serrated perforation along the left edge is a CSS mask (a repeating radial-gradient punching out half circles), so the notches are genuinely transparent and work on any background; after a successful apply, a small stub piece covers the right end of the ticket, then tears away, rotating and falling as it fades.
  • Case normalisation: every input path (typing, pasting, controlled values) goes through normalizeCouponCode — uppercase, strip all whitespace, truncate to maxLength.
  • Enter submission is IME-safe: pressing Enter while composing with a Chinese (or other) IME does not submit, and the Safari case where keydown fires only after compositionend is handled too.
  • Validation is asynchronous: pressing Apply again while checking is ignored; an exception thrown by onApply counts as a failure with a generic error message; and if the user removes and retries while a check is in flight, the stale earlier result is discarded instead of overwriting the new state.

Accessibility

  • The label is associated with the input via htmlFor; on failure aria-invalid is set and aria-describedby points at the message area
  • Status messages (checking / failure reason / applied) live in a role="status", aria-live="polite" region, so changes are announced by screen readers
  • The Apply button sets aria-busy and is disabled while checking, preventing duplicate submissions; the remove button carries an aria-label that includes the coupon code
  • The applied ticket chip is a role="group" whose aria-label fully describes the code, discount amount and label; the per-digit rolling amount animation is hidden from assistive technology
  • When the user has reduce motion enabled at the system level, the tear-off, rolling amount and shake animations are disabled and the state switches directly