← каталог
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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
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