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.jsonnpx shadcn@latest add @manniche/cta-signupcta-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.
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