# Sentence Form CTA

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

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/sentence-form-cta).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/sentence-form-cta
- Screenshot: https://shadcn.exchange/shot/keyframeui/sentence-form-cta/light.png
- Registry item: https://keyframeui.com/r/sentence-form-cta.json
- Categories: blocks, cta-sections
- Listed: 2026-10-27, source last changed 2026-10-27

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/sentence-form-cta.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/sentence-form.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/cta-sections/sentence-form-cta

## Files

### `registry/blocks/sentence-form-cta.tsx`

```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>
  );
}
```
