Motiva
Мои коллекции
← каталог

Newsletter Inline

Строчная форма подписки с двумя ловушками для ботов: правильно спрятанное поле-приманка (не display:none, плюс tabindex=-1 и aria-hidden) и порог времени заполнения. Результат объявляется в области aria-live, место под него зарезервировано.

cssclickintermediatereduced-motion ✓
↗

Редактируемые поля

Секция «newsletter». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subОписаниеrichtext
placeholderПодсказка в полеtext
buttonLabelНадпись на кнопкеtext
okTextОтвет при успехеtext
errTextОтвет при ошибкеtext
noteМелкая строка под формойrichtext

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
eyebrow?string"рассылка"
title?string"Раз в две недели, без воды"
sub?string"Что мы поменяли, почему и чем это кончи…
placeholder?string"you@example.com"
buttonLabel?string"Подписаться"
note?string"Письма два раза в месяц. Отписка — одно…Мелкая строка под формой: про частоту писем и отписку.
okText?string"Готово. Проверьте почту — там письмо с …
errText?string"Не отправилось. Попробуйте ещё раз чере…
minFillMs?number2000Сколько миллисекунд форма считает «слишком быстро». Человек не успевает осмысленно заполнить поле быстрее.
onSubmit?(email: string) => void | Promise<void>; background?: ReactNode; tone?: "dark" | "light"; scrim?: number;—Куда на самом деле отправлять. Не задан — форма только показывает состояния. Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой. Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. Плотность скрима между фоном и текстом; 0 — выключить.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/newsletter-inline.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

const css = `.nli { position: relative; isolation: isolate; width: 100%;
  padding-block: var(--ds-section-y, var(--ds-8, 80px));
  background: var(--ds-bg, oklch(0.14 0.02 275));
  color: var(--ds-fg, oklch(0.95 0.01 265));
  font-family: var(--ds-font, system-ui, sans-serif); }
.nli-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px));
  display: grid; justify-items: center; text-align: center; }

.nli-eyebrow { margin: 0 0 var(--ds-3, 12px); text-transform: uppercase;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.14em;
  color: var(--ds-accent, oklch(0.78 0.16 300)); }
.nli-h { margin: 0; max-width: 20ch; text-wrap: balance;
  font-size: var(--ds-text-xl, 2rem); font-weight: 780; line-height: 1.1;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }
.nli-sub { margin: var(--ds-3, 12px) 0 0; max-width: 52ch; text-wrap: pretty;
  font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.55);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

.nli-form { margin: var(--ds-6, 36px) 0 0; width: min(100%, 520px);
  display: flex; gap: var(--ds-2, 8px); flex-wrap: wrap; }
.nli-field { flex: 1 1 14ch; min-width: 0; }
.nli-input { width: 100%; padding: 12px 14px; font: inherit;
  font-size: var(--ds-text-md, 1rem); color: var(--ds-fg, oklch(0.95 0.01 265));
  background: var(--ds-surface, oklch(0.18 0.02 275));
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  border-radius: var(--ds-radius, 12px); outline: none;
  transition: border-color var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.nli-input::placeholder { color: var(--ds-muted, oklch(0.72 0.02 265)); opacity: 0.7; }
.nli-input:focus-visible { border-color: var(--ds-accent, oklch(0.78 0.16 300)); }
/* краснеет только после ввода мусора, а не на пустом обязательном поле */
.nli-input:user-invalid { border-color: var(--ds-danger, oklch(0.63 0.2 25)); }

.nli-btn { flex: none; padding: 12px 20px; font: inherit; font-weight: 640;
  border-radius: var(--ds-radius, 12px); border: 1px solid transparent; cursor: pointer;
  background: var(--ds-accent, oklch(0.78 0.16 300));
  color: var(--ds-accent-fg, oklch(0.16 0.02 275));
  transition: background var(--ds-fast, 0.18s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
              opacity var(--ds-fast, 0.18s) linear; }
.nli-btn:is(:hover, :focus-visible) { background: var(--ds-accent-hover, oklch(0.82 0.16 300)); }
.nli-btn[disabled] { opacity: 0.6; cursor: default; }

/* ПРИМАНКА: по вычисленным стилям поле видимое (не display:none, не visibility:hidden),
   но лежит вне кадра и обрезано в точку. Плюс tabindex=-1 и aria-hidden — иначе
   ловушка ловит клавиатурного пользователя и диктора вместо бота. */
.nli-hp { position: absolute; left: -9999px; top: 0; width: 1px; height: 1px;
  opacity: 0; pointer-events: none; }

/* место под ответ занято заранее: 1lh — ровно одна строка этого текста */
.nli-msg { margin: var(--ds-3, 12px) 0 0; min-height: 1lh; text-align: center;
  font-size: var(--ds-text-sm, 0.86rem); line-height: 1.5;
  color: var(--ds-success, oklch(0.72 0.15 155)); }
.nli[data-state="error"] .nli-msg { color: var(--ds-danger, oklch(0.63 0.2 25)); }
.nli-note { margin: var(--ds-4, 18px) 0 0; max-width: 46ch; text-align: center;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem); line-height: 1.6;
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

/* СОКЕТ ФОНА. Слой на z-index -1 внутри своего stacking context: поверх заливки
   секции, но под содержимым — вёрстке формы знать о нём не нужно. */
.nli-bg { position: absolute; inset: 0; z-index: -1; }
.nli[data-slot-bg] { background: transparent; }
.nli-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.nli[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
  --ds-border: oklch(0.98 0.01 265 / 0.16); --ds-surface: oklch(0.16 0.02 275 / 0.66);
  color: var(--ds-fg); }
.nli[data-tone="dark"] .nli-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--nli-scrim, 0.5) * 0.9)); }
.nli[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
  --ds-border: oklch(0.2 0.02 265 / 0.14); --ds-surface: oklch(1 0 0 / 0.72);
  color: var(--ds-fg); }
.nli[data-tone="light"] .nli-bg::after {
  background: oklch(0.99 0 0 / calc(var(--nli-scrim, 0.5) * 0.9)); }

@media (prefers-reduced-motion: reduce) {
  .nli-input, .nli-btn { transition: none; }
}
`;
import type { FormEvent, ReactNode } from "react";
import { useRef, useState } from "react";

export interface NewsletterInlineProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  placeholder?: string;
  buttonLabel?: string;
  /** Мелкая строка под формой: про частоту писем и отписку. */
  note?: string;
  okText?: string;
  errText?: string;
  /**
   * Сколько миллисекунд форма считает «слишком быстро».
   * Человек не успевает осмысленно заполнить поле быстрее.
   */
  minFillMs?: number;
  /** Куда на самом деле отправлять. Не задан — форма только показывает состояния. */
  onSubmit?: (email: string) => void | Promise<void>;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом; 0 — выключить. */
  scrim?: number;
}

/**
 * Строчная форма подписки с двумя ловушками для ботов и честным объявлением результата.
 *
 * ЛОВУШКА ПЕРВАЯ — поле-приманка. Его не видно человеку, но оно есть в разметке, и наивный
 * бот, заполняющий всё подряд, себя выдаёт. Прячут его обычно `display: none` — и зря:
 * это первое, что проверяет любой скрапер, поле просто пропускается. Здесь поле вынесено
 * за пределы кадра и обрезано в точку, то есть по вычисленным стилям оно ВИДИМОЕ.
 * Ещё две строчки обязательны, и про них забывают чаще всего: `tabindex="-1"` (иначе
 * клавиатурный пользователь проваливается в невидимое поле между почтой и кнопкой) и
 * `aria-hidden` (иначе диктор предлагает заполнить «Компания», которой на экране нет).
 *
 * ЛОВУШКА ВТОРАЯ — время. Приманку обходят, скопировав реальную форму; время заполнения
 * подделать сложнее. Форма запоминает момент, когда её показали, и отправку раньше порога
 * считает автоматической. Порог маленький — человек и не заметит, а поток мусора падает.
 *
 * Обе ловушки отвечают боту «готово». Не потому, что это красиво, а потому, что честная
 * ошибка — это отладочная информация: увидев её, автор скрипта поправит бота и вернётся.
 * Гарантий ни одна из ловушек не даёт: это фильтр от примитивных ботов, а не защита.
 *
 * Результат объявляется в области `aria-live`: без неё отправка для незрячего проходит
 * молча — фокус остался на кнопке, а сообщение появилось где-то в стороне и никем не
 * прочитано. Место под сообщение зарезервировано (`min-height: 1lh`), иначе строка
 * выталкивает мелкий текст под формой ровно в момент нажатия.
 */
export function NewsletterInline({
  eyebrow = "рассылка",
  title = "Раз в две недели, без воды",
  sub = "Что мы поменяли, почему и чем это кончилось. Разбор одного решения в каждом письме.",
  placeholder = "you@example.com",
  buttonLabel = "Подписаться",
  note = "Письма два раза в месяц. Отписка — одной ссылкой в подвале любого письма.",
  okText = "Готово. Проверьте почту — там письмо с подтверждением.",
  errText = "Не отправилось. Попробуйте ещё раз через минуту.",
  minFillMs = 2000,
  onSubmit,
  background,
  tone,
  scrim = 0.5,
}: NewsletterInlineProps) {
  const shownAt = useRef(Date.now());
  const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle");
  const [msg, setMsg] = useState("");

  const handle = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    const data = new FormData(form);
    const bait = String(data.get("company") ?? "");
    const tooFast = Date.now() - shownAt.current < minFillMs;

    // Боту — тот же ответ, что человеку. Разница только в том, что дальше ничего не происходит.
    if (bait || tooFast) {
      setState("done");
      setMsg(okText);
      return;
    }

    setState("busy");
    try {
      await onSubmit?.(String(data.get("email") ?? ""));
      form.dataset.sent = String(Number(form.dataset.sent ?? "0") + 1);
      setState("done");
      setMsg(okText);
      form.reset();
    } catch {
      setState("error");
      setMsg(errText);
    }
  };

  return (
    <section
      className="nli"
      data-state={state}
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--nli-scrim" as string]: scrim }}
    >
      <style href="motiva-newsletter-inline" precedence="medium">{css}</style>

      {background && <div className="nli-bg">{background}</div>}

      <div className="nli-in">
        {/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
        {eyebrow && <p className="nli-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
        {title && <h2 className="nli-h" data-slot="title">{title}</h2>}
        {sub && <p className="nli-sub" data-slot="sub">{sub}</p>}

        <form className="nli-form" onSubmit={handle} data-sent="0">
          <div className="nli-field">
            <div className="nli-hp" aria-hidden="true">
              <label htmlFor="nli-company">Компания</label>
              <input id="nli-company" type="text" name="company" tabIndex={-1} autoComplete="off" />
            </div>
            <input
              className="nli-input"
              type="email"
              name="email"
              required
              autoComplete="email"
              inputMode="email"
              placeholder={placeholder}
              aria-label="Адрес почты"
            />
          </div>
          <button className="nli-btn" type="submit" disabled={state === "busy"}>
            {state === "busy" ? "Отправляем…" : buttonLabel}
          </button>
        </form>

        <p className="nli-msg" role="status" aria-live="polite">
          {msg}
        </p>
        {note && <p className="nli-note" data-slot="note">{note}</p>}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0