MKT UI

Forms & booking

Submission states, delivering enquiries, and handing off to a scheduler.

MKT UI provides the form UI and one integration point. It does not send email, store submissions, or run a booking backend — those are yours, and the library is explicit about that rather than pretending otherwise.

The submission callback

Every form takes an onSubmit that returns a promise. Resolve it and the form shows its success state; throw and it shows failure with a retry.

<QuoteRequestForm
  services={services}
  onSubmit={async (values) => {
    const response = await fetch("/api/quote", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(values),
    });

    if (!response.ok) {
      // The message is shown to the visitor, so make it useful.
      throw new Error("We couldn't send your request. Please try again.");
    }
  }}
/>

Validate on the server

Client-side validation is a convenience for the visitor. It is trivially bypassed. Validate the payload again in your handler, and add your own rate limiting, spam protection, and storage — MKT UI does none of those.

Submission states

Every form covers all of these. They are requirements, not variants: a form that cannot tell a visitor their enquiry failed will silently lose leads.

StateBehavior
EmptyFields blank, nothing marked invalid
Pre-filleddefaultValues populate fields, e.g. from a campaign URL
InvalidInline errors tied to fields with aria-describedby, focus moves to the first error
SubmittingButton disabled, status announced politely, repeat submits blocked
SuccessConfirmation replaces the form, with an option to send another
Failurerole="alert" announcement, retry button, all input preserved
DisabledWhole form off with a visible explanation

Seeing the failure state

The demos use a deterministic trigger rather than a random failure, so you can inspect the error path on demand: put fail anywhere in the email address.

Open preview (opens in a new tab)

Duplicate submissions

useFormSubmission guards re-entry with a ref rather than state:

const inFlight = React.useRef(false);

const submit = async (values) => {
  if (inFlight.current) return;
  inFlight.current = true;
  // …
};

Two clicks dispatched in the same tick both read the same rendered state, so a state-based guard lets the second one through. The disabled button is the visible defense; this is the one that actually holds.

Input is never cleared on failure

Nothing in the failure path touches field values. Someone who just typed six lines about their boiler still has them after a dropped request.

Building your own form

Reuse the three pieces the built-in forms are made of rather than starting over:

import { Field, FieldGroup } from "@/components/field";
import { SubmissionStatus } from "@/components/submission-status";
import { useFormSubmission } from "@/hooks/use-form-submission";

function NewsletterForm({ onSubmit }) {
  const submission = useFormSubmission({ onSubmit });

  return (
    <form onSubmit={handleSubmit((v) => submission.submit(v))} noValidate>
      <Field label="Email" required error={errors.email?.message}>
        {(field) => (
          <Input type="email" autoComplete="email" {...register("email")} {...field} />
        )}
      </Field>

      <SubmissionStatus state={submission.state} />

      <Button type="submit" disabled={submission.isSubmitting}>
        Subscribe
      </Button>
    </form>
  );
}

Field owns the aria-describedby / aria-invalid / id plumbing between a label, its hint, and its error. That is the part most hand-rolled forms get subtly wrong, so it is worth reusing even for a two-field form.

Accessibility notes

  • Labels are always visible. A placeholder is not a label — it disappears the moment someone starts typing, which is exactly when they need it.
  • Required fields get a visual * and aria-required, with the word "required" available to screen readers.
  • Validation runs on blur, then live once a field has been touched. Validating from the first keystroke tells someone their email is invalid while they are still typing it.
  • Failures use role="alert"; successes use role="status". The live region is always in the DOM and only its contents change, because a region inserted at the same moment as its text is frequently missed.

Booking

For appointments, link out to a scheduler first. It is the honest option: MKT UI has no way to know a business's real availability.

<Button asChild>
  <a href="https://cal.com/your-handle/consultation">Book a consultation</a>
</Button>

If you embed a scheduler instead, give the iframe a descriptive title and always keep a plain link as a fallback for when the embed is blocked.

MKT UI does not ship an availability picker. A calendar UI that looks like it books a real appointment but does not is worse than a plain link — the visitor believes they have an appointment and nobody turns up.

What MKT UI will never do

  • Collect or persist visitor data. The demos on this site send nothing anywhere.
  • Ship a CRM, email delivery, or analytics integration.
  • Claim a form converts better than any other form.

On this page