A closing call to action where the request-access form is one sentence: "Hi, I'm ___ from ___." Picks fly up into the blanks, and on send the sentence settles into a thank-you that uses the visitor's first name.
registry/blocks/sentence-form-cta.tsx
"use client";
import * as React from "react";
import {
SentenceForm,
SentenceFormField,
type SentenceFormFieldProps,
type SentenceFormValues,
} from "@/components/ui/sentence-form";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours, including
* the sentence itself and what each blank asks for.
* ─────────────────────────────────────────────────────────── */
/** The one accent: the eyebrow, the send button and the icons. */
const ACCENT = "#6e56cf";
const EYEBROW = "Early access";
const HEADLINE = "Tell us what your agents should do.";
const SUBLINE =
"One sentence is enough. A founder reads every request and replies within a day, usually with a time to talk.";
/** The blanks. `name` is also the key in the values your request receives. */
const BLANKS = {
name: { name: "name", label: "Your name", placeholder: "your name", autoComplete: "name", required: true },
company: {
name: "company",
label: "Company",
placeholder: "your company",
autoComplete: "organization",
required: true,
},
volume: {
name: "volume",
label: "Agent runs a day",
placeholder: "how many",
options: ["a handful of", "hundreds of", "thousands of", "millions of"],
required: true,
},
goals: {
name: "goals",
label: "What you want",
placeholder: "something better",
options: ["fewer hallucinations", "on-prem", "faster evals", "lower costs"],
multiple: true,
required: true,
},
email: {
name: "email",
label: "Work email",
placeholder: "you@company.com",
type: "email",
autoComplete: "email",
hint: "Only used to reply",
required: true,
},
} satisfies Record<string, SentenceFormFieldProps>;
/** The sentence, with a blank wherever one goes. */
const SENTENCE: (string | { blank: keyof typeof BLANKS })[] = [
"Hi, I'm ",
{ blank: "name" },
" from ",
{ blank: "company" },
". We run ",
{ blank: "volume" },
" agents a day and want ",
{ blank: "goals" },
". Reach me at ",
{ blank: "email" },
".",
];
const SUBMIT_LABEL = "Request access";
const ERROR_MESSAGE = "That didn't send. Your answers are still here, so try again.";
/** The thank-you that takes the button's place. */
const thanks = (values: SentenceFormValues) => ({
lead: `Thanks, ${firstName(values.name)}.`,
text: `You're on the list, and we'll write to ${values.email} within a day.`,
});
const TRUST_POINTS = [
{ icon: "reply", title: "A reply within a day", text: "From a founder, not an email sequence." },
{ icon: "shield", title: "SOC 2 Type II", text: "Audited every year. Your data never trains a model." },
{ icon: "bolt", title: "Live in a week", text: "Most teams ship their first agent in five days." },
] as const;
/** Stands in for your request: send the values to your API, CRM or inbox here. */
async function requestAccess(values: SentenceFormValues) {
void values;
await new Promise((resolve) => setTimeout(resolve, 900));
}
/* ── Section ─────────────────────────────────────────────── */
function firstName(name: string | string[] | undefined) {
return String(name ?? "").trim().split(/\s+/)[0];
}
const focusRing =
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent)";
export function SentenceFormCta() {
return (
<section
style={{ "--accent": ACCENT } as React.CSSProperties}
// The form's send button falls back to the shadcn theme colors; point them at this palette.
className="relative isolate overflow-hidden bg-white text-zinc-950 [--background:#ffffff] [--foreground:#09090b] dark:bg-zinc-950 dark:text-white dark:[--background:#09090b] dark:[--foreground:#fafafa]"
>
<div
aria-hidden
className="pointer-events-none absolute -top-48 right-[-12rem] -z-10 size-[36rem] rounded-full bg-(--accent)/15 blur-[120px] dark:bg-(--accent)/25"
/>
{/* Phones: copy, form, trust points. Tablets: the trust points move above the form. From lg the
form takes the right column, so it can grow as it's filled in without moving anything. */}
<div className="mx-auto grid max-w-6xl gap-12 px-6 py-24 sm:py-32 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:grid-rows-[auto_1fr] lg:gap-x-16 lg:gap-y-12">
<div className="sm:order-1 lg:order-none lg:col-start-1 lg:row-start-1 lg:pt-4">
<p className="inline-flex items-center gap-2 rounded-full bg-(--accent)/10 py-1 pr-3 pl-2.5 text-sm font-medium text-(--accent) dark:text-[color-mix(in_oklab,var(--accent),white_45%)]">
<span aria-hidden className="size-1.5 rounded-full bg-current" />
{EYEBROW}
</p>
<h2 className="mt-5 max-w-[14ch] text-4xl/[1.05] font-semibold tracking-[-0.03em] text-balance sm:text-5xl/[1.02] lg:text-6xl/[1]">
{HEADLINE}
</h2>
<p className="mt-6 max-w-md text-base/7 text-pretty text-zinc-600 sm:text-lg/8 dark:text-zinc-400">
{SUBLINE}
</p>
</div>
<div className="rounded-3xl bg-zinc-50 p-6 ring-1 ring-zinc-950/[0.06] sm:order-3 sm:p-10 lg:order-none lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:self-start dark:bg-white/[0.03] dark:ring-white/10">
<SentenceForm
onSubmit={requestAccess}
className="text-[22px]/[1.5] font-medium tracking-[-0.015em] sm:text-[28px]/[1.45]"
submitLabel={SUBMIT_LABEL}
submitClassName="tracking-normal data-[ready=true]:bg-(--accent) data-[ready=true]:text-white"
trayClassName="tracking-normal"
errorMessage={ERROR_MESSAGE}
success={({ values, edit }) => {
const message = thanks(values);
return (
<div className="flex items-start gap-3 text-base/7 tracking-normal">
<span
aria-hidden
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-full bg-(--accent) text-white"
>
<CheckIcon className="size-3.5" />
</span>
<p className="text-pretty text-zinc-600 dark:text-zinc-400">
<span className="font-semibold text-zinc-950 dark:text-white">{message.lead}</span>{" "}
{message.text}{" "}
<button
type="button"
onClick={edit}
className={`cursor-pointer rounded-sm font-medium text-zinc-950 underline decoration-zinc-950/30 underline-offset-4 hover:decoration-zinc-950 dark:text-white dark:decoration-white/30 dark:hover:decoration-white ${focusRing}`}
>
Edit
</button>
</p>
</div>
);
}}
>
{SENTENCE.map((part, i) =>
typeof part === "string" ? part : <SentenceFormField key={i} {...BLANKS[part.blank]} />,
)}
</SentenceForm>
</div>
<ul className="grid gap-6 border-t border-zinc-950/10 pt-8 sm:order-2 sm:grid-cols-3 lg:order-none lg:col-start-1 lg:row-start-2 lg:grid-cols-1 lg:self-start dark:border-white/10">
{TRUST_POINTS.map((point) => (
<li key={point.title} className="flex gap-3.5">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl bg-(--accent)/10 text-(--accent) dark:text-[color-mix(in_oklab,var(--accent),white_45%)]"
>
<TrustIcon icon={point.icon} />
</span>
<div>
<p className="font-medium">{point.title}</p>
<p className="mt-1 text-sm/6 text-pretty text-zinc-600 dark:text-zinc-400">{point.text}</p>
</div>
</li>
))}
</ul>
</div>
</section>
);
}
/* ── Icons ───────────────────────────────────────────────── */
function TrustIcon({ icon }: { icon: (typeof TRUST_POINTS)[number]["icon"] }) {
const paths = {
reply: "M4 5.5A2.5 2.5 0 0 1 6.5 3h7A2.5 2.5 0 0 1 16 5.5v5a2.5 2.5 0 0 1-2.5 2.5H9l-3.5 3v-3h0A1.5 1.5 0 0 1 4 11.5Z",
shield: "M10 2.5 4 4.75v4.6c0 3.7 2.55 6.6 6 8.15 3.45-1.55 6-4.45 6-8.15v-4.6L10 2.5ZM7.5 10l1.75 1.75L12.75 8",
bolt: "M11 2.5 4.5 11h5l-1 6.5L15 9h-5l1-6.5Z",
};
return (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-[18px]">
<path d={paths[icon]} />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden className={className}>
<path d="m3.5 8.5 3 3 6-7" />
</svg>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/cta-sections/sentence-form-cta