Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

CTA signup

Et kort til tilmelding med e-mail: en tjekliste, ét felt med validering, en sendetilstand og en bekræftelse med adressen. Sender intet selv; du giver onSubmit.

Forhåndsvisning

React

Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/cta-signup.json
npx shadcn@latest add @manniche/cta-signup
cta-signup.tsx
// An email capture as one quiet Tiles card: a headline, a short "what you get" checklist and the form. The form
// validates on blur and on submit, keeps its width while it sends (the label fades out and a spinner fades in on the
// same button), and on success is replaced by a confirmation that says the address back in mono. Signature: the
// success state shows the address the user typed, so they can spot a typo at once. Nothing is sent by the section:
// `onSubmit` receives the values and returns a promise it awaits.
// Screen readers get a labelled section with one h2 and a list; the field has a real label, errors are tied to it
// with aria-invalid and aria-describedby and focus moves to it when it is invalid; the success text is announced
// through a polite live region. Under reduced motion the spinner stops turning and the success state appears at once.
import { Check } from 'lucide-react'
import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type FormEvent,
  type ReactNode,
} from 'react'
import { cn } from '@/lib/utils'

export type CtaSignupValues = {
  /** The address the user typed, trimmed. */
  email: string
}

export type CtaSignupLabels = {
  /** The visible label of the field. Default "Email address". */
  email?: string
  /** The submit button. Default "Subscribe". */
  submit?: string
  /** Hidden text on the button while it sends. Default "Sending". */
  sending?: string
  /** Shown when the field is empty. Default "Enter your email address." */
  required?: string
  /** Shown when the address does not look right. Default "That does not look like an email address. Check it for typos, for example name@example.com." */
  invalid?: string
  /** Shown when `onSubmit` rejects. Default "Something went wrong and nothing was saved. Try again in a moment." */
  failed?: string
  /** The example text inside the empty field. Default "name@example.com". */
  placeholder?: string
  /** The heading of the success state. Default "You are on the list." */
  successTitle?: string
  /**
   * The sentence in front of the address in the success state. Default "Signed up as". If your `onSubmit` sends a
   * confirmation mail, say so here, such as "We sent a confirmation to".
   */
  successText?: string
  /** The button in the success state that goes back to the form. Default "Use a different address". */
  reset?: string
  /** The small heading of the checklist, hidden from sight and read by screen readers. Default "What you get". */
  includes?: string
}

const DEFAULT_LABELS: Required<CtaSignupLabels> = {
  email: 'Email address',
  submit: 'Subscribe',
  sending: 'Sending',
  required: 'Enter your email address.',
  invalid: 'That does not look like an email address. Check it for typos, for example name@example.com.',
  failed: 'Something went wrong and nothing was saved. Try again in a moment.',
  placeholder: 'name@example.com',
  successTitle: 'You are on the list.',
  successText: 'Signed up as',
  reset: 'Use a different address',
  includes: 'What you get',
}

export type CtaSignupProps = Omit<ComponentProps<'section'>, 'children' | 'title' | 'onSubmit'> & {
  /** The headline of the card. */
  title: ReactNode
  /** One sentence under the headline. */
  description?: ReactNode
  /** What the person gets, 3 to 5 short items. Shown as a list with check marks. */
  items: ReactNode[]
  /** The consent line under the form, such as a sentence with a link to the privacy policy. */
  consent?: ReactNode
  /**
   * Called with the values when the form is valid. Return a promise; the button shows its sending state until it
   * settles. Resolve to show the success state, reject to show an error and keep the typed text. The section itself
   * never sends anything.
   */
  onSubmit?: (values: CtaSignupValues) => Promise<void> | void
  /** The `autocomplete` token of the field. Default "email". */
  autoComplete?: string
  /** Text for the interface itself, for translation. */
  labels?: CtaSignupLabels
}

type Status = 'idle' | 'sending' | 'sent'

// A forgiving check: something@something.tld without spaces. The server decides what is really valid.
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/

/** The email capture card: checklist, one field, validation, a sending state and a confirmation that echoes the address. */
export function CtaSignup({
  title,
  description,
  items,
  consent,
  onSubmit,
  autoComplete = 'email',
  labels,
  className,
  ...rest
}: CtaSignupProps) {
  const l = { ...DEFAULT_LABELS, ...labels }
  const uid = useId()
  const headingId = `${uid}-title`
  const inputId = `${uid}-email`
  const errorId = `${uid}-error`
  const consentId = `${uid}-consent`
  const inputRef = useRef<HTMLInputElement>(null)
  const resetRef = useRef<HTMLButtonElement>(null)
  const alive = useRef(false)
  const [value, setValue] = useState('')
  const [touched, setTouched] = useState(false)
  const [status, setStatus] = useState<Status>('idle')
  const [failed, setFailed] = useState(false)
  const [sentTo, setSentTo] = useState('')

  useEffect(() => {
    alive.current = true
    return () => {
      alive.current = false
    }
  }, [])

  // The submit button goes away with the form, so focus moves to the success state's only control instead of
  // falling back to the page. The live region still reads the confirmation.
  useEffect(() => {
    if (status === 'sent') resetRef.current?.focus()
  }, [status])

  const trimmed = value.trim()
  const problem = !trimmed ? l.required : !EMAIL.test(trimmed) ? l.invalid : null
  const showProblem = touched && problem
  const sending = status === 'sending'

  const submit = useCallback(
    async (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault()
      if (sending) return
      setTouched(true)
      if (problem) {
        inputRef.current?.focus()
        return
      }
      setFailed(false)
      setStatus('sending')
      try {
        await onSubmit?.({ email: trimmed })
        if (!alive.current) return
        setSentTo(trimmed)
        setStatus('sent')
      } catch {
        if (!alive.current) return
        setFailed(true)
        setStatus('idle')
      }
    },
    [sending, problem, onSubmit, trimmed],
  )

  const reset = () => {
    setValue('')
    setTouched(false)
    setFailed(false)
    setStatus('idle')
    // Wait for the form to render again before moving focus into it.
    requestAnimationFrame(() => inputRef.current?.focus())
  }

  const describedBy = [showProblem || failed ? errorId : null, consent ? consentId : null].filter(Boolean).join(' ') || undefined

  return (
    <section
      aria-labelledby={headingId}
      {...rest}
      className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 @3xl:py-20', className)}
    >
      <div className="grid gap-10 rounded-[calc(var(--radius)*2+2px)] bg-card px-6 py-8 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)] @min-[40rem]:px-10 @min-[40rem]:py-12 @min-[56rem]:grid-cols-2 @min-[56rem]:gap-16 @min-[64rem]:px-14 @min-[64rem]:py-14">
        <div className="min-w-0">
          <h2
            id={headingId}
            className="max-w-[20ch] text-[clamp(1.75rem,6cqi,3rem)] leading-[1.04] font-extrabold tracking-[-0.04em] text-balance"
            style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}
          >
            {title}
          </h2>
          {description && <p className="mt-4 max-w-[46ch] text-base leading-relaxed text-pretty text-muted-foreground">{description}</p>}
          <h3 className="sr-only">{l.includes}</h3>
          <ul className="mt-7 grid gap-3">
            {items.map((item, i) => (
              <li key={i} className="flex items-start gap-3 text-[15px] leading-snug">
                <span aria-hidden className="mt-px grid size-5 flex-none place-items-center rounded-full bg-muted text-foreground">
                  <Check className="size-3" strokeWidth={3} />
                </span>
                <span className="min-w-0 text-pretty">{item}</span>
              </li>
            ))}
          </ul>
        </div>

        <div className="min-w-0 self-center">
          <div role="status" aria-live="polite">
            {status === 'sent' && (
              <div className="rounded-[14px] bg-muted p-6 transition-[opacity,transform] duration-300 ease-out-quint starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">
                <span aria-hidden className="grid size-9 place-items-center rounded-full bg-foreground text-background">
                  <Check className="size-[18px]" strokeWidth={2.5} />
                </span>
                <p className="mt-4 text-xl leading-tight font-semibold tracking-[-0.02em]">{l.successTitle}</p>
                <p className="mt-2 text-[15px] leading-relaxed text-muted-foreground">{l.successText}</p>
                <p className="mt-1 font-mono text-[13px] leading-relaxed break-all tabular-nums">{sentTo}</p>
                <button
                  ref={resetRef}
                  type="button"
                  onClick={reset}
                  className="mt-4 -ml-3 inline-flex min-h-11 items-center rounded-full px-3 text-sm font-medium underline underline-offset-4 transition-[opacity,transform] duration-150 ease-out-quint hover:opacity-70 active:scale-[0.97] motion-reduce:transition-none"
                >
                  {l.reset}
                </button>
              </div>
            )}
          </div>

          {status !== 'sent' && (
            <form noValidate onSubmit={submit} aria-busy={sending || undefined}>
              <label htmlFor={inputId} className="block text-sm font-medium">
                {l.email}
              </label>
              <div className="mt-2 flex flex-col gap-3 @min-[32rem]:flex-row">
                <input
                  ref={inputRef}
                  id={inputId}
                  name="email"
                  type="email"
                  inputMode="email"
                  autoComplete={autoComplete}
                  autoCapitalize="none"
                  spellCheck={false}
                  required
                  value={value}
                  readOnly={sending}
                  onChange={(e) => setValue(e.target.value)}
                  onBlur={() => value && setTouched(true)}
                  aria-invalid={showProblem ? true : undefined}
                  aria-describedby={describedBy}
                  className={cn(
                    'h-12 w-full min-w-0 rounded-[14px] @min-[32rem]:flex-1 bg-background px-4 text-base text-foreground shadow-[inset_0_0_0_1px_var(--border)] outline-offset-2 placeholder:text-muted-foreground/70',
                    showProblem && 'shadow-[inset_0_0_0_1.5px_var(--destructive)]',
                  )}
                  placeholder={l.placeholder}
                />
                <button
                  type="submit"
                  aria-disabled={sending || undefined}
                  className="relative inline-flex h-12 flex-none items-center justify-center rounded-full bg-primary px-6 text-[15px] font-medium whitespace-nowrap text-primary-foreground transition-[transform,opacity] duration-150 ease-out-quint hover:opacity-90 active:scale-[0.97] aria-disabled:cursor-progress motion-reduce:transition-none motion-reduce:active:scale-100"
                >
                  <span className={cn('transition-[opacity,transform] duration-150 ease-out-quint motion-reduce:transition-none', sending && 'scale-95 opacity-0')}>
                    {l.submit}
                  </span>
                  <span
                    aria-hidden
                    className={cn(
                      'absolute inset-0 grid place-items-center transition-[opacity,transform] duration-150 ease-out-quint motion-reduce:transition-none',
                      sending ? 'opacity-100' : 'scale-90 opacity-0',
                    )}
                  >
                    <svg viewBox="0 0 24 24" className="size-5 animate-spin motion-reduce:animate-none" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
                      <circle cx="12" cy="12" r="9" opacity="0.25" />
                      <path d="M21 12a9 9 0 0 0-9-9" />
                    </svg>
                  </span>
                  {sending && <span className="sr-only">{l.sending}</span>}
                </button>
              </div>
              <div id={errorId} role={failed ? 'alert' : undefined} className="empty:hidden">
                {showProblem ? (
                  <p className="mt-2 text-sm leading-snug text-destructive">{problem}</p>
                ) : failed ? (
                  <p className="mt-2 text-sm leading-snug text-destructive">{l.failed}</p>
                ) : null}
              </div>
              {consent && (
                <p id={consentId} className="mt-4 text-[13px] leading-relaxed text-pretty text-muted-foreground [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:text-foreground">
                  {consent}
                </p>
              )}
            </form>
          )}
        </div>
      </div>
    </section>
  )
}
cta-signup-demo.tsx
import { CtaSignup } from '@/components/cta-signup'

// The demo "sends" by waiting a moment. An address that starts with "fail" rejects, to show the error state.
const fakeSend = ({ email }: { email: string }) =>
  new Promise<void>((resolve, reject) => setTimeout(() => (email.startsWith('fail') ? reject(new Error('demo')) : resolve()), 1200))

export default function CtaSignupDemo() {
  return (
    <CtaSignup
      title="Notes from the studio, once a month."
      description="What we are building in Munich and Aalborg, what we learned and what we would do differently."
      items={[
        'One short letter on the first Monday of the month',
        'Working files and templates we use ourselves',
        'First word when we open a spot for a new project',
        'Unsubscribe with one click, any time',
      ]}
      consent={
        <>
          We use your address only to send this letter. Read the{' '}
          <a href="https://example.com/privacy">privacy policy</a>.
        </>
      }
      onSubmit={fakeSend}
    />
  )
}

HTML

Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.

Åbn i ny fane

Farven i din egen app

Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .dark.

:root {
  --primary: oklch(0.52 0.2 262);
  --primary-foreground: oklch(0.99 0 0);
  --accent: oklch(0.95 0.034 262);
  --accent-foreground: oklch(0.36 0.140 262);
  --ring: oklch(0.52 0.2 262);
}

.dark {
  --primary: oklch(0.74 0.150 262);
  --primary-foreground: oklch(0.18 0.024 262);
  --accent: oklch(0.29 0.050 262);
  --accent-foreground: oklch(0.9 0.050 262);
  --ring: oklch(0.74 0.150 262);
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/cta-signup.json

Afhængigheder

Filer

  • cta-signup.tsx
  • cta-signup.html

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.