Opt-in form

An SMS consent form whose exact shape survived a real TCR/carrier review. Not legal advice — but it is field-tested compliance UI, free to restyle.

Submitting without the box checked signs you up for nothing — no messages will be sent.

Try both paths: submitting with and without the box checked.

Installation

npx shadcn@latest add @handset/opt-in-form

No API dependency — you decide where submissions go.

Why it looks like this

Carrier reviewers rejected an earlier version of this form for "forced opt-in": a required phone field plus a required consent checkbox means the user can't complete the form without agreeing to marketing. The compliant shape — the one that passed — makes the checkbox explicitly optional, labels it so, and treats an unchecked submission as "store nothing, send nothing," with a visible "not enrolled" result. The disclosure covers the CTIA checklist: brand name, message frequency, "consent is not a condition of purchase," msg & data rates, STOP/HELP, and links to your privacy policy and terms.

Usage

import { OptInForm } from "@/components/handset/opt-in-form";

<OptInForm
  brandName="Brightside Property Co"       // as registered on your campaign
  messageFrequency="up to 4 messages per month"
  privacyUrl="/privacy"
  termsUrl="/terms"
  onSubmit={async ({ phone, consented }) => {
    if (!consented) return;                 // store nothing, send nothing
    await fetch("/api/optins", {            // your consent registry
      method: "POST",
      body: JSON.stringify({ phone, language: "v1", at: new Date() }),
    });
  }}
/>

Record the exact disclosure language and timestamp with each consent — that record is what you show a carrier auditor. Send the confirmation text through your Handset number so STOP handling is automatic.

Props

Props
PropTypeDefaultDescription
brandNamestringYour brand as registered on the 10DLC campaign.
messageFrequencystring"recurring messages"Match your campaign registration, e.g. "up to 4 messages per month".
privacyUrlstringLinked in the disclosure.
termsUrlstringLinked in the disclosure.
onSubmit({ phone, consented }) => Promise<void>Receives every submission; consented=false must mean no enrollment.
classNamestringMerged onto the form.