# Newsletter Signup

> Newsletter signup card — inline email form or CTA-only variant, async status tracking, three tones, full i18n.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/newsletter-signup).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/newsletter-signup
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/newsletter-signup-light-dc47a4af844ea716.png
- Registry item: https://ui.ilinxa.com/r/newsletter-signup.json
- Categories: marketing
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/newsletter-signup.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: `button`, `input`

## Files

### `src/registry/components/marketing/newsletter-signup/newsletter-signup.tsx`

```tsx
"use client";

import { memo, useCallback, useId, useMemo, useState } from "react";
import type { FormEvent, MouseEvent } from "react";
import { CtaOnlyPart } from "./parts/cta-only";
import { InlineFormPart } from "./parts/inline-form";
import {
  DEFAULT_LABELS,
  type NewsletterSignupProps,
  type NewsletterSignupStatus,
  type ResolvedPartProps,
} from "./types";

/**
 * NewsletterSignup — brand-tinted CTA card with email signup.
 *
 * Two variants via the `variant` prop:
 * - `inline-form` (default) — email input + Subscribe button.
 * - `cta-only` — full-width button only; for "click to open signup flow" patterns.
 *
 * Form state can be controlled (`value` + `onChange`) or uncontrolled
 * (`defaultValue`). Status (`idle | pending | success | error`) can be
 * controlled (`status` prop) or auto-tracked from a Promise-returning
 * `onSubmit`. Fully i18n via the `labels` prop.
 */
function NewsletterSignupImpl(props: NewsletterSignupProps) {
  const {
    variant = "inline-form",
    tone = "primary",
    headingAs = "h3",
    value: controlledValue,
    defaultValue = "",
    onChange,
    onSubmit,
    status: controlledStatus,
    onStatusChange,
    labels: labelsProp,
    className,
    buttonClassName,
    id: idProp,
  } = props;

  const generatedId = useId();
  const baseId = idProp ?? generatedId;
  const titleId = `${baseId}-title`;
  const bodyId = `${baseId}-body`;

  const labels = useMemo(
    () => ({ ...DEFAULT_LABELS, ...labelsProp }),
    [labelsProp],
  );

  const [internalValue, setInternalValue] = useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const email = isControlled ? controlledValue : internalValue;

  const handleEmailChange = useCallback(
    (next: string) => {
      if (!isControlled) setInternalValue(next);
      onChange?.(next);
    },
    [isControlled, onChange],
  );

  const [internalStatus, setInternalStatus] = useState<NewsletterSignupStatus>("idle");
  const status = controlledStatus ?? internalStatus;

  const setStatus = useCallback(
    (next: NewsletterSignupStatus) => {
      if (controlledStatus === undefined) setInternalStatus(next);
      onStatusChange?.(next);
    },
    [controlledStatus, onStatusChange],
  );

  const handleSubmit = useCallback(
    (event: FormEvent | MouseEvent) => {
      event.preventDefault();
      if (status === "pending") return;
      if (!onSubmit) return;

      const result = onSubmit(email);

      if (result instanceof Promise) {
        setStatus("pending");
        result
          .then(() => setStatus("success"))
          .catch(() => setStatus("error"));
      }
    },
    [email, onSubmit, setStatus, status],
  );

  const partProps: ResolvedPartProps = {
    variant,
    tone,
    headingAs,
    email,
    onEmailChange: handleEmailChange,
    status,
    labels,
    onSubmit: handleSubmit,
    className,
    buttonClassName,
    titleId,
    bodyId,
  };

  if (variant === "cta-only") return <CtaOnlyPart {...partProps} />;
  return <InlineFormPart {...partProps} />;
}

export const NewsletterSignup = memo(NewsletterSignupImpl);
NewsletterSignup.displayName = "NewsletterSignup";

export default NewsletterSignup;
```

### `src/registry/components/marketing/newsletter-signup/index.ts`

```ts
export { NewsletterSignup, default } from "./newsletter-signup";
export type {
  NewsletterSignupHeadingLevel,
  NewsletterSignupLabels,
  NewsletterSignupProps,
  NewsletterSignupStatus,
  NewsletterSignupTone,
  NewsletterSignupVariant,
} from "./types";
```

### `src/registry/components/marketing/newsletter-signup/types.ts`

```ts
import type { FormEvent, MouseEvent } from "react";

export type NewsletterSignupVariant = "inline-form" | "cta-only";

export type NewsletterSignupTone = "primary" | "accent" | "muted";

export type NewsletterSignupStatus = "idle" | "pending" | "success" | "error";

export type NewsletterSignupHeadingLevel = "h2" | "h3" | "h4";

export interface NewsletterSignupLabels {
  /** Headline. Default: 'Join our newsletter'. */
  title?: string;
  /** Body / description. Default: 'Latest updates, straight to your inbox.'. */
  body?: string;
  /**
   * Email input placeholder. Default: 'you@example.com' — a format-hint
   * showing the expected shape (per Nielsen "recognition over recall"). Pass
   * 'Email address' if you prefer a generic prompt over an example.
   */
  placeholder?: string;
  /** Email input aria-label. Default: 'Email address'. */
  emailLabel?: string;
  /** Button text. Default: 'Subscribe'. */
  button?: string;
  /** Success message after `onSubmit` resolves. Default: "Thanks! You're subscribed.". */
  successMessage?: string;
  /** Error message after `onSubmit` rejects. Default: 'Something went wrong. Please try again.'. */
  errorMessage?: string;
}

export const DEFAULT_LABELS: Required<NewsletterSignupLabels> = {
  title: "Join our newsletter",
  body: "Latest updates, straight to your inbox.",
  placeholder: "you@example.com",
  emailLabel: "Email address",
  button: "Subscribe",
  successMessage: "Thanks! You're subscribed.",
  errorMessage: "Something went wrong. Please try again.",
};

export interface NewsletterSignupProps {
  /** Visual variant. Default: 'inline-form'. */
  variant?: NewsletterSignupVariant;

  /** Color tint. Default: 'primary'. */
  tone?: NewsletterSignupTone;

  /** Heading semantic level. Default: 'h3'. */
  headingAs?: NewsletterSignupHeadingLevel;

  /** Controlled email value. */
  value?: string;
  /** Uncontrolled initial email value. */
  defaultValue?: string;
  /** Email change callback. */
  onChange?: (value: string) => void;

  /** Submit handler. Return Promise to auto-track pending → success/error. */
  onSubmit?: (email: string) => void | Promise<void>;

  /** Controlled status. If omitted, derives from `onSubmit`'s return. */
  status?: NewsletterSignupStatus;
  /** Status change callback. */
  onStatusChange?: (status: NewsletterSignupStatus) => void;

  /** Localized labels. Defaults are English. */
  labels?: NewsletterSignupLabels;

  /** Override classes for the root container. */
  className?: string;
  /** Override classes for the action button. */
  buttonClassName?: string;

  /** Override the heading id. Default: useId(). */
  id?: string;
}

/** Internal shape passed from the root to each variant part. */
export interface ResolvedPartProps {
  variant: NewsletterSignupVariant;
  tone: NewsletterSignupTone;
  headingAs: NewsletterSignupHeadingLevel;
  email: string;
  onEmailChange: (value: string) => void;
  status: NewsletterSignupStatus;
  labels: Required<NewsletterSignupLabels>;
  onSubmit: (event: FormEvent | MouseEvent) => void;
  className: string | undefined;
  buttonClassName: string | undefined;
  titleId: string;
  bodyId: string;
}
```

### `src/registry/components/marketing/newsletter-signup/parts/inline-form.tsx`

```tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ResolvedPartProps } from "../types";
import { resolveTone } from "./tone";
import { StatusMessage } from "./status-message";

/**
 * Inline-form variant — email input + Subscribe button in a row.
 * The default for sidebar / footer CTAs that capture email inline.
 */
export function InlineFormPart({
  tone,
  headingAs,
  email,
  onEmailChange,
  status,
  labels,
  onSubmit,
  className,
  buttonClassName,
  titleId,
  bodyId,
}: ResolvedPartProps) {
  const HeadingTag = headingAs;
  const toneClasses = resolveTone(tone);

  return (
    <div
      role="region"
      aria-labelledby={titleId}
      className={cn(
        "rounded-2xl border p-6",
        toneClasses.frame,
        className,
      )}
    >
      <HeadingTag
        id={titleId}
        className={cn("mb-2 font-serif text-lg font-bold", toneClasses.heading)}
      >
        {labels.title}
      </HeadingTag>
      <p id={bodyId} className="mb-4 text-sm text-muted-foreground">
        {labels.body}
      </p>

      <form onSubmit={onSubmit} className="flex gap-2">
        <Input
          type="email"
          required
          value={email}
          onChange={(event) => onEmailChange(event.target.value)}
          placeholder={labels.placeholder}
          aria-label={labels.emailLabel}
          aria-describedby={bodyId}
          aria-invalid={status === "error"}
          disabled={status === "pending"}
          className="flex-1"
        />
        <Button
          type="submit"
          disabled={status === "pending"}
          aria-busy={status === "pending"}
          className={buttonClassName}
        >
          {labels.button}
        </Button>
      </form>

      <StatusMessage status={status} labels={labels} />
    </div>
  );
}
```

### `src/registry/components/marketing/newsletter-signup/parts/cta-only.tsx`

```tsx
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ResolvedPartProps } from "../types";
import { resolveTone } from "./tone";
import { StatusMessage } from "./status-message";

/**
 * CTA-only variant — full-width Subscribe button without an email input.
 * For "click to open the signup flow elsewhere" patterns; consumer's
 * `onSubmit` typically opens a modal or navigates.
 *
 * onSubmit is called with an empty string ('') since no email is captured here.
 */
export function CtaOnlyPart({
  tone,
  headingAs,
  status,
  labels,
  onSubmit,
  className,
  buttonClassName,
  titleId,
  bodyId,
}: ResolvedPartProps) {
  const HeadingTag = headingAs;
  const toneClasses = resolveTone(tone);

  return (
    <div
      role="region"
      aria-labelledby={titleId}
      className={cn(
        "rounded-2xl border p-6",
        toneClasses.frame,
        className,
      )}
    >
      <HeadingTag
        id={titleId}
        className={cn("mb-2 font-serif text-lg font-bold", toneClasses.heading)}
      >
        {labels.title}
      </HeadingTag>
      <p id={bodyId} className="mb-4 text-sm text-muted-foreground">
        {labels.body}
      </p>

      <Button
        type="button"
        onClick={onSubmit}
        disabled={status === "pending"}
        aria-busy={status === "pending"}
        className={cn("w-full", buttonClassName)}
      >
        {labels.button}
      </Button>

      <StatusMessage status={status} labels={labels} />
    </div>
  );
}
```

### `src/registry/components/marketing/newsletter-signup/parts/status-message.tsx`

```tsx
import type { NewsletterSignupStatus, NewsletterSignupLabels } from "../types";

/**
 * Status message renderer. Lives below the action area in both variants.
 *
 * - `success` renders in muted text inside an aria-live="polite" region.
 * - `error` renders in destructive text inside a role="alert" region.
 * - `idle` and `pending` render an empty live-region (so the assistive
 *   technology has a stable announcement target when status changes).
 */
export function StatusMessage({
  status,
  labels,
}: {
  status: NewsletterSignupStatus;
  labels: Required<NewsletterSignupLabels>;
}) {
  if (status === "error") {
    return (
      <div role="alert" className="mt-3 text-sm text-destructive">
        {labels.errorMessage}
      </div>
    );
  }

  return (
    <div aria-live="polite" className="mt-3 min-h-[1.25rem] text-sm text-muted-foreground">
      {status === "success" ? labels.successMessage : null}
    </div>
  );
}
```

### `src/registry/components/marketing/newsletter-signup/parts/tone.ts`

```ts
import type { NewsletterSignupTone } from "../types";

/**
 * Tone → Tailwind class fragments. Each tone gets a frame (background +
 * border) + a heading color modifier. The frame is `rounded-2xl border p-6`
 * by default, applied at the part level.
 */
const TONE_MAP: Record<NewsletterSignupTone, { frame: string; heading: string }> = {
  primary: {
    frame: "border-primary/20 bg-primary/5",
    heading: "text-foreground",
  },
  accent: {
    frame: "border-accent/40 bg-accent/30",
    heading: "text-accent-foreground",
  },
  muted: {
    frame: "border-border/50 bg-muted/40",
    heading: "text-foreground",
  },
};

export const resolveTone = (tone: NewsletterSignupTone) => TONE_MAP[tone];
```
