Coupon Field
A promo-code row: Apply shows a spinner, a valid code becomes a removable chip and the total counts down.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/coupon-field.json'use client'
import { useEffect, useRef, useState } from 'react'
import './coupon-field.css'
type Result = { ok: true; percent: number } | { ok: false; error: string }
export interface CouponFieldProps {
subtotal: number
validate: (code: string) => Promise<Result>
currency?: string
defaultOpen?: boolean
fill?: 'dark' | 'light'
className?: string
}
function useTween(target: number) {
const [v, setV] = useState(target)
const from = useRef(target)
useEffect(() => {
const start = performance.now(), a = from.current
let raf = 0
const step = (t: number) => {
const k = Math.min(1, (t - start) / 500), e = 1 - (1 - k) ** 3
const cur = a + (target - a) * e
setV(cur); from.current = cur
if (k < 1) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [target])
return v
}
/** Promo code entry with an animated total. */
export function CouponField({ subtotal, validate, currency = 'USD', defaultOpen = false, fill = 'dark', className }: CouponFieldProps) {
const [open, setOpen] = useState(defaultOpen)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [shake, setShake] = useState(0)
const [applied, setApplied] = useState<{ code: string; percent: number } | null>(null)
const money = (c: number) => (c / 100).toLocaleString('en-US', { style: 'currency', currency })
const disc = applied ? Math.round((subtotal * applied.percent) / 100) : 0
const total = useTween(subtotal - disc)
const apply = async () => {
setBusy(true); setErr('')
const r = await validate(code.trim().toUpperCase())
setBusy(false)
if (r.ok) { setApplied({ code: code.trim().toUpperCase(), percent: r.percent }); setCode('') }
else { setErr(r.error); setShake((s) => s + 1) }
}
return (
<div className={['sd-cpn', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cpn__line"><span>Subtotal</span><b>{money(subtotal)}</b></div>
{applied && <div className="sd-cpn__line sd-cpn__line--disc"><span>Discount ({applied.percent}%)</span><b>−{money(disc)}</b></div>}
<div className="sd-cpn__tot"><span>Total</span><span aria-live="polite">{money(Math.round(total))}</span></div>
{applied ? (
<span className="sd-cpn__chip">{applied.code}<button type="button" aria-label={`Remove code ${applied.code}`} onClick={() => setApplied(null)}><svg viewBox="0 0 10 10" aria-hidden><path d="m2.5 2.5 5 5m0-5-5 5" /></svg></button></span>
) : open ? (
<>
<form key={shake} className="sd-cpn__row" data-shake={shake ? '' : undefined} onSubmit={(e) => { e.preventDefault(); if (code.trim() && !busy) apply() }}>
<input className="sd-cpn__in" aria-label="Promo code" placeholder="Promo code" value={code} autoFocus onChange={(e) => { setCode(e.target.value); setErr('') }} />
<button type="submit" className="sd-cpn__ap" disabled={!code.trim() || busy} aria-label={busy ? 'Checking code' : 'Apply code'}>{busy ? <span className="sd-cpn__spin" aria-hidden /> : 'Apply'}</button>
</form>
{err && <p className="sd-cpn__err" role="alert">{err}</p>}
</>
) : (
<button type="button" className="sd-cpn__link" onClick={() => setOpen(true)}>Have a promo code?</button>
)}
</div>
)
}.sd-cpn { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-cpn[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-cpn button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cpn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cpn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cpn { width: min(100%, 320px); padding: 16px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-cpn__line { display: flex; justify-content: space-between; font-size: 13.5px; padding: 5px 0; color: var(--mute); }
.sd-cpn__line b { color: var(--fg); font-weight: 500; }
.sd-cpn__line--disc b { animation: sd-cpn-in 300ms ease; }
.sd-cpn__tot { display: flex; justify-content: space-between; align-items: baseline; padding-top: 12px; margin-top: 8px; border-top: 1px solid var(--line); font-weight: 650; font-size: 18px; }
.sd-cpn__link { margin: 12px 0 2px; font-size: 13px; color: var(--mute); text-decoration: underline; text-underline-offset: 3px; }
.sd-cpn__link:hover { color: var(--fg); }
.sd-cpn__row { display: flex; gap: 6px; margin-top: 12px; animation: sd-cpn-in 220ms ease; }
.sd-cpn__row[data-shake] { animation: sd-cpn-shake 320ms; }
.sd-cpn__in { flex: 1; min-width: 0; height: 40px; padding: 0 12px; border: 0; border-radius: 10px; outline: 0; background: var(--soft); color: var(--fg); font: 600 13px/1 ui-monospace, monospace; letter-spacing: 0.08em; text-transform: uppercase; box-shadow: inset 0 0 0 1px transparent; }
.sd-cpn__in:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-cpn__in::placeholder { letter-spacing: 0; font-weight: 400; color: var(--mute); text-transform: none; }
.sd-cpn__ap { position: relative; width: 74px; height: 40px; border-radius: 10px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 13px; }
.sd-cpn__ap:disabled { opacity: 0.4; cursor: default; }
.sd-cpn__spin { display: inline-block; width: 14px; height: 14px; border-radius: 99px; border: 2px solid currentColor; border-right-color: transparent; animation: sd-cpn-rot 700ms linear infinite; vertical-align: -2px; }
@keyframes sd-cpn-rot { to { transform: rotate(360deg); } }
.sd-cpn__err { margin: 6px 2px 0; font-size: 12px; color: var(--fg); }
.sd-cpn__chip { display: inline-flex; align-items: center; gap: 8px; margin-top: 12px; height: 32px; padding: 0 6px 0 11px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--fg); font: 600 12px/1 ui-monospace, monospace; letter-spacing: 0.06em; animation: sd-cpn-pop 320ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-cpn__chip button { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 99px; background: var(--soft) !important; }
.sd-cpn__chip svg { width: 10px; height: 10px; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
@keyframes sd-cpn-in { from { opacity: 0; transform: translateY(-4px); } }
@keyframes sd-cpn-pop { from { transform: scale(0.7); opacity: 0; } }
@keyframes sd-cpn-shake { 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
@media (prefers-reduced-motion: reduce) { .sd-cpn *, .sd-cpn *::before, .sd-cpn *::after { animation: none !important; transition: none !important; } }Overview
Collapsed it is a single “Have a code?” link. Open, the code is upper-cased as you type; Apply checks it with your async validator.
A valid code turns into a chip with the saving, and the order total animates to its new value. An invalid one shakes the field and says why.
When to use
- Checkout summaries and cart drawers.
When not to use
- Where codes are rare: an exposed field invites people to go hunting for one.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/coupon-field.json$
npx shadcn@latest add https://sundaeui.dev/r/coupon-field.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/coupon-field.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/coupon-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy coupon-field.tsx and coupon-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CouponField } from '@/components/sundae/coupon-field'
<CouponField />Props
| Prop | Type | Default |
|---|---|---|
subtotal*Subtotal in cents. | number | — |
validate*Checks a code. | (code: string) => Promise<{ ok: true; percent: number } | { ok: false; error: string }> | — |
currencyISO currency. | string | 'USD' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The code field is labelled and errors use role="alert".
- The applied chip’s remove button names the code.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cpn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Coupon Field free?
- Yes. Coupon Field is free for personal and commercial projects, with full source on this page.
- How do I install Coupon Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/coupon-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Coupon Field support keyboard and screen readers?
- The code field is labelled and errors use role="alert". The applied chip’s remove button names the code.
- Does Coupon Field respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All checkout →- Pro
Tender Checkout
Checkout · Refined - Pro
Currency Swap
Checkout · Refined - Pro
Tip Selector
Checkout · Refined - Pro
Free Shipping Progress
Checkout · Refined - Pro
Cart Line Item
Checkout · Refined - Pro
Payment Methods
Checkout · Refined