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
Built from
FieldButtonIcon

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.40

Code
"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

PropTypeDefaultWhat it does
onApply(code: string) => Promise<{ ok: true; saving?; label? } | { ok: false; message }>noneChecks a code. A rejected promise shows a generic try again message.
value{ code; saving?; label? } | nullnoneThe applied code, when controlled.
defaultValue{ code; saving?; label? } | nullnullThe applied code at first, when uncontrolled.
onValueChange(value: CouponCode | null) => voidnoneCalled when a code is applied or removed.
onRemove(code: string) => voidnoneCalled with the code that was removed.
labelstring"Promo code"Visible label of the field.
placeholderstringnonePlaceholder of the field.
collapsiblebooleanfalseStarts as a disclosure that opens onto the field.
collapsedLabelstring"Have a promo code?"Text of the disclosure.
namestringnoneSubmits the applied code with a surrounding form under this name.
currencystring"USD"ISO 4217 code for the saving.
localestring"en-US"Formats the saving.
disabledbooleanfalseDisables the field and its buttons.
...restOmit<ComponentProps<"div">, "defaultValue" | "onChange" | "children">noneEvery native div attribute, applied to the root.

Anatomy

root.eq-coupon-fielddata-state is empty, pending or applied; aria-busy while checking.
disclosure.eq-coupon-field-disclosureA native <details>, only when collapsible.
toggle.eq-coupon-field-toggle
chevron.eq-coupon-field-chevron
entry.eq-coupon-field-entryThe field row and its error.
row.eq-coupon-field-row
field.eq-coupon-field-fieldThe Field.
apply.eq-coupon-field-applyA type="button" Button that shows a spinner while checking.
error.eq-coupon-field-errorAlways present and polite, so a message is announced when it arrives.
applied.eq-coupon-field-appliedA group that takes focus once a code is applied.
tag.eq-coupon-field-tag
details.eq-coupon-field-details
code.eq-coupon-field-codeA monospace <code>.
label.eq-coupon-field-label
saving.eq-coupon-field-saving
remove.eq-coupon-field-removeNamed "Remove code SPRING10".
status.eq-coupon-field-statusA hidden live region for applied and removed.

Keyboard

EnterIn the field, applies the code without submitting a surrounding form.
EnterSpaceOn 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index