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

CTA Glow

Финальный призыв: кнопка с бегущим бликом и дышащее пятно акцента, анимированное transform и opacity — а не background-position или filter, которые перерисовывают большую площадь каждый кадр.

cssautohoverrevealbasic~2 КБreduced-motion ✓

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

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

ПропЧто этоТип
titleЗаголовокtext
subОписаниеrichtext
ctaКнопкаtext
noteСтрока под кнопкойtext

Все пропсы

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

ПропТипПо умолчаниюЧто это
title?string"Расскажите про проект"
sub?string"Ответим в течение дня, оценку пришлём з…
cta?string"Написать нам"
note?string"или пишите на hello@studio.dev"Мелкая строка под кнопкой.
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция гасит собственную заливку и своё пятно, чтобы не спорить за слой.
tone?"dark" | "light"Какой текст читается поверх фона. Берётся из `mount.tone` подставленной записи: тёмное сияние требует светлого текста, иначе получаем тёмное на тёмном.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/cta-glow.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

import type { ReactNode } from "react";
import { ShimmerButton } from "@/components/shimmer-button";

export interface CtaGlowProps {
  title?: string;
  sub?: string;
  cta?: string;
  /** Мелкая строка под кнопкой. */
  note?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция гасит собственную заливку и своё пятно, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /**
   * Какой текст читается поверх фона. Берётся из `mount.tone` подставленной записи:
   * тёмное сияние требует светлого текста, иначе получаем тёмное на тёмном.
   */
  tone?: "dark" | "light";
  /**
   * Плотность скрима — вуали между фоном и текстом (формат «0.55»); 0 — выключить.
   * Нужен ВСЕГДА, когда под текстом живой фон: у сияния или шейдера яркость гуляет по кадру,
   * и подобрать цвет текста, читаемый и на тёмном провале, и на светлой вспышке, нельзя —
   * контраст надо не угадывать, а создавать.
   */
  scrim?: number;
}

/**
 * Финальный призыв: крупная фраза, кнопка с бегущим бликом и медленно дышащее пятно
 * акцента за спиной.
 *
 * Пятно анимируется `transform` + `opacity` — не `background-position` и не `filter`.
 * Разница видна на слабом железе: первый вариант живёт в композиторе и не стоит ничего,
 * второй заставляет перерисовывать большую площадь каждый кадр ради того же вида.
 *
 * Кнопка взята из записи `shimmer-button`: секция объявляет её в `registryDependencies`,
 * поэтому приезжает вместе с ней и правится в одном месте.
 */
export function CtaGlow({
  title = "Расскажите про проект",
  sub = "Ответим в течение дня, оценку пришлём за три. Без брифов на двадцать страниц.",
  cta = "Написать нам",
  note = "или пишите на hello@studio.dev",
  background,
  tone,
  scrim = 0.55,
}: CtaGlowProps) {
  return (
    <section
      className="ctg"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ ["--ctg-scrim" as string]: scrim }}
    >
      <style>{`
        .ctg { position: relative; width: 100%; overflow: hidden; text-align: center;
          isolation: isolate;
          padding-block: var(--ds-section-y, var(--ds-9, 120px));
          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); }
        /* дышащее пятно: только transform и opacity — композитор, ноль перерисовки */
        .ctg::before { content: ""; position: absolute; left: 50%; top: 50%; width: min(760px, 90vw);
          aspect-ratio: 1; translate: -50% -50%; pointer-events: none; border-radius: 50%;
          background: radial-gradient(circle,
            var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.16)), transparent 62%);
          animation: ctg-breathe 9s ease-in-out infinite; }

        /* СОКЕТ ФОНА. Слой лежит под контентом и не ловит указатель; когда он занят,
           секция снимает свою заливку и своё пятно — иначе они спорят за один слой,
           и подставленный эффект виден только по краям */
        .ctg-bg { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
        .ctg[data-slot-bg] { background: transparent; }
        .ctg[data-slot-bg]::before { display: none; }
        /* скрим: гасит фон ТАМ, ГДЕ текст, и отпускает к краям — эффект остаётся виден,
           а контраст перестаёт зависеть от того, что именно воткнули в сокет */
        .ctg-bg::after { content: ""; position: absolute; inset: 0; }
        .ctg[data-tone="dark"] .ctg-bg::after {
          background: radial-gradient(ellipse 72% 62% at 50% 50%,
            oklch(0.09 0.02 265 / calc(var(--ctg-scrim, 0.55) * 1.15)),
            oklch(0.09 0.02 265 / calc(var(--ctg-scrim, 0.55) * 0.35)) 76%); }
        .ctg[data-tone="light"] .ctg-bg::after {
          background: radial-gradient(ellipse 72% 62% at 50% 50%,
            oklch(0.99 0 0 / calc(var(--ctg-scrim, 0.55) * 1.15)),
            oklch(0.99 0 0 / calc(var(--ctg-scrim, 0.55) * 0.35)) 76%); }
        /* тон приходит из mount.tone подставленного фона: тёмное сияние требует светлого текста */
        .ctg[data-tone="dark"] { color: oklch(0.96 0.01 265); }
        .ctg[data-tone="dark"] .ctg-sub, .ctg[data-tone="dark"] .ctg-note {
          color: oklch(0.86 0.02 265); }
        .ctg[data-tone="light"] { color: oklch(0.2 0.02 265); }
        .ctg[data-tone="light"] .ctg-sub, .ctg[data-tone="light"] .ctg-note {
          color: oklch(0.38 0.02 265); }

        .ctg-in { position: relative; z-index: 1; max-width: min(var(--ds-container, 1120px), 720px);
          margin-inline: auto; padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
        .ctg-h { margin: 0; font-size: var(--ds-text-xl, 2.1rem); font-weight: 780;
          letter-spacing: var(--ds-tracking-tight, -0.03em); line-height: 1.1; }
        .ctg-sub { margin: var(--ds-4, 16px) auto var(--ds-6, 36px); max-width: 46ch;
          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)); }
        .ctg-note { margin: var(--ds-4, 16px) 0 0;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); color: var(--ds-muted, oklch(0.72 0.02 265));
          opacity: 0.8; }

        @keyframes ctg-breathe {
          0%, 100% { transform: scale(0.94); opacity: 0.75; }
          50% { transform: scale(1.06); opacity: 1; }
        }
        @media (prefers-reduced-motion: reduce) { .ctg::before { animation: none; } }
        html[data-shot] .ctg::before { animation: none; }
      `}</style>

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

      <div className="ctg-in">
        <h2 className="ctg-h">{title}</h2>
        {sub && <p className="ctg-sub">{sub}</p>}
        <ShimmerButton>{cta}</ShimmerButton>
        {note && <p className="ctg-note">{note}</p>}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0