bottega

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

Usage

Usagetsx
<NewsletterSignup />

Props

PropTypeDefaultDescription
headingstringShip UI fasterMain heading.
subtextstringNew components, design drops, and early access — straight to your inbox.Supporting text below the heading.
buttonLabelstringSubscribeSubmit button label.
placeholderstringyou@example.comInput placeholder.
privacyNotestringNo spam, ever. Unsubscribe any time.Privacy microcopy shown below the form.
classNamestringExtra 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&#39;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