sections
Newsletter Signup
An email-capture section card: heading + subtext, an inline email input + submit button (side-by-side on desktop, stacked on mobile), and a privacy microcopy line. Manages all state internally — invalid email surfaces an accessible inline error (aria-describedby, aria-invalid, role=alert); a valid submission transitions to a polished success state ('You're on the list.' + animated checkmark badge). The card sits on a bg-muted surface with a faint accent-tinted radial glow and a hairline border. The whole card reveals on scroll entry via a Motion fade-up. All copy overridable via props.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/newsletter-signupUsage
Usagetsx
<NewsletterSignup />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | Ship UI faster | Main heading. |
| subtext | string | New components, design drops, and early access — straight to your inbox. | Supporting text below the heading. |
| buttonLabel | string | Subscribe | Submit button label. |
| placeholder | string | you@example.com | Input placeholder. |
| privacyNote | string | No spam, ever. Unsubscribe any time. | Privacy microcopy shown below the form. |
| className | string | — | Extra class names merged onto the outer wrapper. |
Source
newsletter-signup.tsxtsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import styles from "./newsletter-signup.module.css";
// Expo-out ease — snappy arrival, long tail. Premium feel.
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;
// Reveal duration in seconds (Motion JS) — distinct from --duration CSS UI-transition token
const REVEAL_DURATION = 0.5;
// Simple email gate — intentionally loose; server validates properly
const EMAIL_RE = /^\S+@\S+\.\S+$/;
type Status = "idle" | "error" | "success";
export interface NewsletterSignupProps {
/** Main heading. */
heading?: string;
/** Supporting text below the heading. */
subtext?: string;
/** Submit button label. */
buttonLabel?: string;
/** Input placeholder. */
placeholder?: string;
/** Privacy microcopy shown below the form. */
privacyNote?: string;
/** Extra class names merged onto the outer wrapper. */
className?: string;
}
function CheckIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden="true"
>
<path
d="M11.5 3.5L5.5 9.5L2.5 6.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function NewsletterSignup({
heading = "Ship UI faster",
subtext = "New components, design drops, and early access — straight to your inbox.",
buttonLabel = "Subscribe",
placeholder = "you@example.com",
privacyNote = "No spam, ever. Unsubscribe any time.",
className,
}: NewsletterSignupProps) {
const sectionRef = useRef<HTMLElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Stable IDs for label↔input + error↔input aria wiring
const uid = useId();
const inputId = `${uid}-email`;
const errorId = `${uid}-error`;
// Mount-gate + null-safe reduced-motion check (Bottega contract):
// SSR and first client render are byte-identical (no transform offset).
// Animation turns on only after hydration for users without reduced-motion.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const inView = useInView(sectionRef, { once: true, amount: 0.3 });
const show = animate && inView;
const [status, setStatus] = useState<Status>("idle");
const [errorMsg, setErrorMsg] = useState("");
const [email, setEmail] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!EMAIL_RE.test(email.trim())) {
setStatus("error");
setErrorMsg("Please enter a valid email address.");
inputRef.current?.focus();
return;
}
setStatus("success");
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setEmail(e.target.value);
// Clear error as user corrects input
if (status === "error") {
setStatus("idle");
setErrorMsg("");
}
}
const inputBorderClass =
status === "error" ? "border-primary" : "border-border";
return (
<motion.section
ref={sectionRef}
aria-label={heading}
className={
["relative w-full max-w-xl mx-auto", className]
.filter(Boolean)
.join(" ")
}
initial={animate ? { y: 28, opacity: 0 } : false}
animate={
show ? { y: 0, opacity: 1 } : animate ? { y: 28, opacity: 0 } : false
}
transition={{ duration: REVEAL_DURATION, ease: EASE_EXPO_OUT }}
>
{/* Card: bg-muted surface + hairline border + accent glow from CSS module */}
<div className={styles.card}>
{/* Ambient accent-tinted radial glow — tokens-only */}
<div aria-hidden="true" className={styles.glow} />
<div className="relative z-10 flex flex-col gap-5">
{/* Heading + subtext */}
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{heading}
</h2>
{subtext && (
<p
className="text-sm leading-relaxed"
style={{
color:
"color-mix(in oklch, var(--foreground) 70%, var(--background))",
}}
>
{subtext}
</p>
)}
</div>
{/* Form / Success swap */}
{status === "success" ? (
<motion.div
key="success"
role="status"
aria-live="polite"
initial={animate ? { opacity: 0, y: 6 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: EASE_EXPO_OUT }}
className="flex items-center gap-2.5 py-0.5 text-sm font-medium text-foreground"
>
{/* Checkmark badge — scale-pop via CSS module keyframe */}
<span className={styles.checkBadge}>
<CheckIcon />
</span>
You're on the list.
</motion.div>
) : (
<form
onSubmit={handleSubmit}
noValidate
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
{/* Input column: label (visually hidden) + input + inline error */}
<div className="flex flex-1 flex-col gap-1">
<label htmlFor={inputId} className="sr-only">
Email address
</label>
<input
ref={inputRef}
id={inputId}
type="email"
name="email"
value={email}
onChange={handleChange}
placeholder={placeholder}
autoComplete="email"
aria-invalid={status === "error" ? true : undefined}
aria-describedby={
status === "error" ? errorId : undefined
}
className={[
"h-10 w-full rounded-[var(--radius)]",
"border px-3 text-sm",
"bg-background text-foreground",
"placeholder:opacity-40",
"transition-[border-color,box-shadow] duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-1 focus-visible:ring-offset-background",
inputBorderClass,
].join(" ")}
/>
{status === "error" && errorMsg && (
<p
id={errorId}
role="alert"
className="text-xs font-medium"
style={{ color: "var(--primary)" }}
>
{errorMsg}
</p>
)}
</div>
{/* Submit button — plain element (no Motion transform), safe to use active:scale */}
<button
type="submit"
className={[
"h-10 shrink-0 select-none",
"inline-flex items-center justify-center",
"rounded-[var(--radius)] px-5",
"bg-primary text-primary-foreground",
"text-sm font-medium",
"transition-[filter,transform] duration-[var(--duration)] ease-[var(--ease)]",
"hover:brightness-90 active:scale-[0.97]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
].join(" ")}
>
{buttonLabel}
</button>
</form>
)}
{/* Privacy microcopy */}
<p
className="text-xs"
style={{
color:
"color-mix(in oklch, var(--foreground) 50%, var(--background))",
}}
>
{privacyNote}
</p>
</div>
</div>
</motion.section>
);
}
Dependencies
- motion
- @bottega/tokens