Commerce
Coupon field
Enters and checks a promo code, then shows it applied with the saving and a remove button.
- Built on
<input><details>aria-invalidaria-describedbyARIA live regionsIntl.NumberFormat- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.8 kB
Code
"use client";
import { CouponField } from "@/components/equip/coupon-field";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Demo() {
return (
<CouponField
style={{ width: 224 }}
placeholder="Try SPRING10"
onApply={async (code) => {
await wait(700);
if (code === "SPRING10") return { ok: true, saving: 12.4 };
if (code === "WINTER25") return { ok: false, message: "This code has expired." };
return { ok: false, message: "We do not recognise that code." };
}}
/>
);
}Examples
Applied
The remove button is named "Remove code SPRING10"; removing it puts focus back in the field.
SPRING1010% off everythingYou save $12.40Code
"use client";
import { CouponField } from "@/components/equip/coupon-field";
export default function Example() {
return (
<CouponField
style={{ maxWidth: 340 }}
defaultValue={{ code: "SPRING10", label: "10% off everything", saving: 12.4 }}
onApply={async (code) =>
code === "SPRING10" ? { ok: true, label: "10% off everything", saving: 12.4 } : { ok: false, message: "We do not recognise that code." }
}
/>
);
}Collapsed until asked for
A native disclosure keeps the field out of the way of people without a code; opening it moves focus into the field.
Have a promo code?
Code
"use client";
import { CouponField } from "@/components/equip/coupon-field";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Example() {
return (
<CouponField
style={{ maxWidth: 340 }}
collapsible
label="Gift card or promo code"
onApply={async (code) => {
await wait(600);
return code === "THANKYOU"
? { ok: true, label: "Free shipping", saving: 6.95 }
: { ok: false, message: "We do not recognise that code." };
}}
/>
);
}Yen, inside a checkout form
Enter applies the code and never submits the form; with name set, the applied code is sent with it.
Code
"use client";
import { CouponField } from "@/components/equip/coupon-field";
import { Button } from "@/components/equip/button";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Example() {
return (
<form
onSubmit={(event) => event.preventDefault()}
style={{ display: "grid", gap: 12, maxWidth: 340 }}
>
<CouponField
name="promo"
currency="JPY"
locale="ja-JP"
placeholder="SAKURA"
onApply={async (code) => {
await wait(700);
return code === "SAKURA"
? { ok: true, label: "15% off", saving: 1820 }
: { ok: false, message: "This code has expired." };
}}
/>
<Button type="submit" variant="accent">
Pay now
</Button>
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
onApply | (code: string) => Promise<{ ok: true; saving?; label? } | { ok: false; message }> | none | Checks a code. A rejected promise shows a generic try again message. |
value | { code; saving?; label? } | null | none | The applied code, when controlled. |
defaultValue | { code; saving?; label? } | null | null | The applied code at first, when uncontrolled. |
onValueChange | (value: CouponCode | null) => void | none | Called when a code is applied or removed. |
onRemove | (code: string) => void | none | Called with the code that was removed. |
label | string | "Promo code" | Visible label of the field. |
placeholder | string | none | Placeholder of the field. |
collapsible | boolean | false | Starts as a disclosure that opens onto the field. |
collapsedLabel | string | "Have a promo code?" | Text of the disclosure. |
name | string | none | Submits the applied code with a surrounding form under this name. |
currency | string | "USD" | ISO 4217 code for the saving. |
locale | string | "en-US" | Formats the saving. |
disabled | boolean | false | Disables the field and its buttons. |
...rest | Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> | none | Every native div attribute, applied to the root. |
Anatomy
| root | .eq-coupon-field | data-state is empty, pending or applied; aria-busy while checking. |
|---|---|---|
| disclosure | .eq-coupon-field-disclosure | A native <details>, only when collapsible. |
| toggle | .eq-coupon-field-toggle | |
| chevron | .eq-coupon-field-chevron | |
| entry | .eq-coupon-field-entry | The field row and its error. |
| row | .eq-coupon-field-row | |
| field | .eq-coupon-field-field | The Field. |
| apply | .eq-coupon-field-apply | A type="button" Button that shows a spinner while checking. |
| error | .eq-coupon-field-error | Always present and polite, so a message is announced when it arrives. |
| applied | .eq-coupon-field-applied | A group that takes focus once a code is applied. |
| tag | .eq-coupon-field-tag | |
| details | .eq-coupon-field-details | |
| code | .eq-coupon-field-code | A monospace <code>. |
| label | .eq-coupon-field-label | |
| saving | .eq-coupon-field-saving | |
| remove | .eq-coupon-field-remove | Named "Remove code SPRING10". |
| status | .eq-coupon-field-status | A hidden live region for applied and removed. |
Keyboard
| Enter | In the field, applies the code without submitting a surrounding form. |
|---|---|
| EnterSpace | On Apply, applies; on the remove button, removes the code; on the disclosure, opens it. |
Notes
- Apply is type="button" and Enter in the field is handled, so the part is safe inside a form.
- The code is uppercased in the value as you type, with the caret kept in place; the label is never transformed.
- While a code is checked the field is read-only, Apply shows a spinner and the root sets aria-busy; focus stays in the field.
- An invalid code sets aria-invalid and aria-describedby on the input, and the message is announced from a polite region.
- Once applied, focus moves to the applied row and "Code SPRING10 applied. You save $12.40." is announced; removing returns focus to the field.
- One code at a time, and one check at a time; a result that arrives after the part unmounts is ignored.
- Check codes on the server as well: the field only shows what onApply returns.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/coupon-field.tsx262 lines
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from "react";
import { Field } from "./field";
import { Button } from "./button";
import { Icon } from "./icon";
import "./coupon-field.css";
/** An applied code. */
export type CouponCode = {
code: string;
/** What it takes off, in major units, shown as "You save $12.00". */
saving?: number;
/** What it does, such as "10% off everything". */
label?: string;
};
export type CouponResult = ({ ok: true } & Omit<CouponCode, "code">) | { ok: false; message: string };
export type CouponFieldProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
/** Checks a code. Resolve { ok: true, saving, label } to apply it or { ok: false, message } to show why not. */
onApply: (code: string) => Promise<CouponResult>;
/** The applied code, when controlled. null means none. */
value?: CouponCode | null;
/** The applied code at first, when uncontrolled. */
defaultValue?: CouponCode | null;
/** Called when a code is applied or removed. */
onValueChange?: (value: CouponCode | null) => void;
/** Called with the code that was removed. */
onRemove?: (code: string) => void;
label?: string;
placeholder?: string;
/** Starts as a "Have a promo code?" disclosure. */
collapsible?: boolean;
/** Text of the disclosure. */
collapsedLabel?: string;
/** Submits the applied code with a form under this name. */
name?: string;
/** ISO 4217 code for the saving. */
currency?: string;
locale?: string;
disabled?: boolean;
};
const normalise = (text: string) => text.toUpperCase();
export function CouponField({
onApply,
value,
defaultValue = null,
onValueChange,
onRemove,
label = "Promo code",
placeholder,
collapsible = false,
collapsedLabel = "Have a promo code?",
name,
currency = "USD",
locale = "en-US",
disabled = false,
className,
...rest
}: CouponFieldProps) {
const id = useId();
const errorId = `${id}-error`;
const inputRef = useRef<HTMLInputElement>(null);
const appliedRef = useRef<HTMLDivElement>(null);
const request = useRef(0);
const caret = useRef<{ start: number; end: number } | null>(null);
const focusNext = useRef<"input" | "applied" | null>(null);
const [inner, setInner] = useState<CouponCode | null>(defaultValue);
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [status, setStatus] = useState("");
const [open, setOpen] = useState(false);
const applied = value !== undefined ? value : inner;
const money = new Intl.NumberFormat(locale, { style: "currency", currency });
/* Uppercasing rewrites the value, so put the caret back where the person was typing. */
useLayoutEffect(() => {
const at = caret.current;
if (at && inputRef.current && document.activeElement === inputRef.current) {
inputRef.current.setSelectionRange(at.start, at.end);
}
caret.current = null;
}, [draft]);
useEffect(() => {
const want = focusNext.current;
if (!want) return;
const target = want === "input" ? inputRef.current : appliedRef.current;
if (!target) return;
target.focus();
focusNext.current = null;
});
/* A result that lands after unmount is dropped. */
useEffect(() => () => void (request.current += 1), []);
const set = (next: CouponCode | null) => {
if (value === undefined) setInner(next);
onValueChange?.(next);
};
const apply = async () => {
if (pending || disabled) return;
const code = draft.trim();
if (!code) {
setError("Enter a code first.");
inputRef.current?.focus();
return;
}
/* Clicking Apply disables it while pending, so keep focus in the field rather than lose it. */
inputRef.current?.focus();
const ticket = ++request.current;
setPending(true);
setError("");
setStatus("");
let result: CouponResult;
try {
result = await onApply(code);
} catch {
result = { ok: false, message: "That code could not be checked. Try again." };
}
if (ticket !== request.current) return;
setPending(false);
if (result.ok) {
const next: CouponCode = { code, saving: result.saving, label: result.label };
setDraft("");
set(next);
setStatus(
`Code ${code} applied.${result.saving != null ? ` You save ${money.format(result.saving)}.` : ""}`,
);
focusNext.current = "applied";
} else {
setError(result.message);
focusNext.current = "input";
}
};
const remove = () => {
if (!applied) return;
const code = applied.code;
set(null);
onRemove?.(code);
setStatus(`Code ${code} removed.`);
setOpen(true);
focusNext.current = "input";
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
/* Enter applies the code and never submits a surrounding form. */
if (event.key === "Enter") {
event.preventDefault();
void apply();
}
};
const entry = (
<div className="eq-coupon-field-entry">
<div className="eq-coupon-field-row">
<Field
ref={inputRef}
id={`${id}-input`}
className="eq-coupon-field-field"
label={label}
placeholder={placeholder}
value={draft}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
enterKeyHint="go"
disabled={disabled}
readOnly={pending}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onKeyDown={onKeyDown}
onChange={(event) => {
const el = event.target;
caret.current = { start: el.selectionStart ?? el.value.length, end: el.selectionEnd ?? el.value.length };
setDraft(normalise(el.value));
if (error) setError("");
}}
/>
<Button
type="button"
className="eq-coupon-field-apply"
variant="outline"
loading={pending}
disabled={disabled}
onClick={() => void apply()}
>
Apply
</Button>
</div>
<p className="eq-coupon-field-error" id={errorId} aria-live="polite">
{error && (
<>
<Icon name="error" size="s" />
{error}
</>
)}
</p>
</div>
);
return (
<div
{...rest}
className={["eq-coupon-field", className].filter(Boolean).join(" ")}
aria-busy={pending || undefined}
data-state={applied ? "applied" : pending ? "pending" : "empty"}
>
{applied ? (
<div className="eq-coupon-field-applied" ref={appliedRef} tabIndex={-1} role="group" aria-label={`${label} applied`}>
<Icon name="tag" size="s" className="eq-coupon-field-tag" />
<span className="eq-coupon-field-details">
<code className="eq-coupon-field-code">{applied.code}</code>
{applied.label && <span className="eq-coupon-field-label">{applied.label}</span>}
{applied.saving != null && (
<span className="eq-coupon-field-saving">You save {money.format(applied.saving)}</span>
)}
</span>
<Button
className="eq-coupon-field-remove"
variant="ghost"
size="s"
aria-label={`Remove code ${applied.code}`}
lead={<Icon name="close" size="s" />}
disabled={disabled}
onClick={remove}
/>
{name && <input type="hidden" name={name} value={applied.code} />}
</div>
) : collapsible ? (
<details
className="eq-coupon-field-disclosure"
open={open}
onToggle={(event) => {
const isOpen = event.currentTarget.open;
setOpen(isOpen);
if (isOpen && !focusNext.current) focusNext.current = "input";
}}
>
<summary className="eq-coupon-field-toggle">
{collapsedLabel}
<Icon name="chevron-down" size="s" className="eq-coupon-field-chevron" />
</summary>
{entry}
</details>
) : (
entry
)}
<p className="eq-coupon-field-status" role="status">
{status}
</p>
</div>
);
}components/equip/coupon-field.css199 lines
@layer equip {
.eq-coupon-field {
box-sizing: border-box;
width: 100%;
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
.eq-coupon-field-entry {
display: flex;
flex-direction: column;
}
/* Field and Apply share a baseline at the box: the button sits beside the input, under the label. */
.eq-coupon-field-row {
display: flex;
align-items: flex-end;
gap: var(--eq-space-2);
}
.eq-coupon-field-field {
flex: 1;
}
.eq-coupon-field-apply {
flex: none;
}
.eq-coupon-field-error {
display: flex;
align-items: flex-start;
gap: var(--eq-space-1);
margin: 0;
color: var(--eq-danger);
font-size: var(--eq-text-s);
line-height: 1.4;
}
.eq-coupon-field-error:not(:empty) {
margin-top: var(--eq-space-1);
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-coupon-field-error:not(:empty) {
opacity: 0;
translate: 0 -2px;
}
}
.eq-coupon-field-error > svg {
margin-top: 1px;
}
/* The applied code: a row with the code, what it saves and a remove button. */
.eq-coupon-field-applied {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-m);
padding: var(--eq-space-1) var(--eq-space-1) var(--eq-space-1) var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-tint);
box-sizing: border-box;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-coupon-field-applied {
opacity: 0;
scale: 0.96;
}
}
.eq-coupon-field-applied:focus {
outline: none;
}
.eq-coupon-field-applied:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-coupon-field-tag {
color: var(--eq-ink-2);
}
.eq-coupon-field-details {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--eq-space-2);
min-width: 0;
}
.eq-coupon-field-code {
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.02em;
overflow-wrap: anywhere;
}
.eq-coupon-field-label {
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.eq-coupon-field-saving {
color: var(--eq-ok);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
}
.eq-coupon-field-remove {
flex: none;
width: var(--eq-h-s);
padding: 0;
color: var(--eq-ink-2);
}
/* The collapsed start: a quiet text toggle. */
.eq-coupon-field-toggle {
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
min-height: 24px;
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
text-decoration: underline;
text-decoration-color: var(--eq-line-strong);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
list-style: none;
cursor: pointer;
transition: text-decoration-color var(--eq-dur) var(--eq-ease);
}
.eq-coupon-field-toggle::-webkit-details-marker {
display: none;
}
@media (hover: hover) {
.eq-coupon-field-toggle:hover {
text-decoration-color: currentColor;
}
}
.eq-coupon-field-toggle:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-coupon-field-chevron {
color: var(--eq-ink-2);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-coupon-field-disclosure[open] .eq-coupon-field-chevron {
rotate: 180deg;
}
.eq-coupon-field-disclosure[open] .eq-coupon-field-entry {
margin-top: var(--eq-space-2);
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-coupon-field-disclosure[open] .eq-coupon-field-entry {
opacity: 0;
translate: 0 -4px;
}
}
.eq-coupon-field-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}