Banner Countdown
Полоса с обратным отсчётом — вариант banner-sticky для события с дедлайном: релиз, конференция, конец распродажи. Табло считается после маунта (на сервере «сейчас» нет, кеш врал бы), до первого тика — прочерк той же ширины: tabular-nums и зарезервированные разряды не дают строке дёрнуться. Тик — секунда, только на видимой вкладке.
Редактируемые поля
Секция «banner». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| label | Плашка | text |
| text | Текст | text |
| deadline | Дедлайн (ISO, «2026-12-01T10:00:00Z») | text |
| doneText | Текст после срока | text |
| ctaLabel | Кнопка | text |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| label? | string | "скоро" | Плашка слева (формат «скоро»). |
| text? | string | "Открываем двери — ранние билеты дешевле… | |
| deadline? | string | — | Дедлайн, ISO-строка (формат «2026-12-01T10:00:00+01:00»). |
| doneText? | string | "Началось! Трансляция уже идёт" | Текст, когда срок вышел. |
| ctaLabel? | string | "Успеть" | |
| ctaHref? | string | "#" | |
| background? | ReactNode | — | Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/banner-countdown.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
const css = `.bcd { position: relative; isolation: isolate; width: 100%;
background: var(--ds-accent, oklch(0.72 0.16 300));
color: var(--ds-accent-fg, oklch(0.99 0.004 265));
font-family: var(--ds-font, system-ui, sans-serif); }
.bcd-in { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
gap: 8px 18px;
max-width: var(--ds-container, 1120px); margin-inline: auto;
padding: 10px var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.bcd-label { flex: none; padding: 3px 9px; border-radius: var(--ds-radius-pill, 999px);
background: oklch(1 0 0 / 0.18);
font: 600 var(--ds-text-xs, 0.72rem) / 1.4 var(--ds-font-mono, ui-monospace, monospace);
letter-spacing: 0.08em; text-transform: uppercase; }
.bcd-text { margin: 0; font-size: var(--ds-text-sm, 0.88rem); font-weight: 550; }
/* табло: tabular-nums + место под цифры зарезервировано — тик не дёргает строку */
.bcd-clock { display: inline-flex; gap: 6px; align-items: baseline;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums; font-weight: 700;
font-size: var(--ds-text-sm, 0.9rem); white-space: nowrap; }
.bcd-clock small { font-size: 0.68em; font-weight: 500; opacity: 0.8; }
.bcd-cta { flex: none; padding: 6px 14px; border-radius: var(--ds-radius-pill, 999px);
background: var(--ds-accent-fg, oklch(0.99 0.004 265));
color: var(--ds-accent, oklch(0.5 0.16 300));
font: 650 var(--ds-text-sm, 0.85rem) / 1 var(--ds-font, system-ui, sans-serif);
text-decoration: none;
transition: padding var(--ds-fast, 0.18s) var(--ds-ease-out, ease-out); }
.bcd-cta:hover, .bcd-cta:focus-visible { padding-inline: 20px; }
.bcd-cta:focus-visible { outline: 2px solid var(--ds-accent-fg, #fff); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .bcd-cta { transition: none; } }
/* СОКЕТ ФОНА — контракт как у остальных секций */
.bcd-bg { position: absolute; inset: 0; z-index: -1; }
.bcd[data-slot-bg] { background: transparent; }
.bcd-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.bcd[data-tone="dark"] { color: oklch(0.96 0.01 265); }
.bcd[data-tone="dark"] .bcd-bg::after {
background: oklch(0.09 0.02 265 / calc(var(--bcd-scrim, 0.5) * 0.9)); }
.bcd[data-tone="light"] { color: oklch(0.2 0.02 265); }
.bcd[data-tone="light"] .bcd-bg::after {
background: oklch(0.99 0 0 / calc(var(--bcd-scrim, 0.5) * 0.9)); }
`;
export interface BannerCountdownProps {
/** Плашка слева (формат «скоро»). */
label?: string;
text?: string;
/** Дедлайн, ISO-строка (формат «2026-12-01T10:00:00+01:00»). */
deadline?: string;
/** Текст, когда срок вышел. */
doneText?: string;
ctaLabel?: string;
ctaHref?: string;
/**
* Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
* Занят — секция снимает собственную заливку, чтобы не спорить за слой.
*/
background?: ReactNode;
/** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
tone?: "dark" | "light";
/** Плотность скрима между фоном и текстом; 0 — выключить. */
scrim?: number;
}
/** Дефолт живой в любую дату: +19 дней от загрузки — демо всегда тикает. */
const DEMO_DEADLINE = () => new Date(Date.now() + 19 * 864e5 + 7 * 36e5).toISOString();
const split = (ms: number) => ({
d: Math.floor(ms / 864e5),
h: Math.floor((ms % 864e5) / 36e5),
m: Math.floor((ms % 36e5) / 6e4),
s: Math.floor((ms % 6e4) / 1000),
});
/**
* Полоса с обратным отсчётом — вариант `banner-sticky` для события с дедлайном:
* релиз, конференция, конец распродажи.
*
* «Сколько осталось» знает только браузер посетителя, поэтому табло считается
* ПОСЛЕ маунта (правило гидрации: на сервере «сейчас» нет, а закешированный HTML
* с чужим остатком врал бы каждому следующему). До первого тика стоит прочерк
* ТОЙ ЖЕ ширины — tabular-nums и зарезервированные разряды не дают строке дёрнуться.
* Тик — setInterval на секунду, только пока вкладка видима; `data-shot` замораживает
* табло на первом значении.
*/
export function BannerCountdown({
label = "скоро",
text = "Открываем двери — ранние билеты дешевле",
deadline,
doneText = "Началось! Трансляция уже идёт",
ctaLabel = "Успеть",
ctaHref = "#",
background,
tone,
scrim = 0.5,
}: BannerCountdownProps) {
const [left, setLeft] = useState<number | null>(null);
useEffect(() => {
const end = new Date(deadline ?? DEMO_DEADLINE()).getTime();
const tick = () => setLeft(Math.max(0, end - Date.now()));
tick();
if (document.documentElement.hasAttribute("data-shot")) return;
const id = setInterval(() => {
if (!document.hidden) tick();
}, 1000);
return () => clearInterval(id);
}, [deadline]);
const t = left != null ? split(left) : null;
const two = (n: number) => String(n).padStart(2, "0");
const shown = left === 0 ? doneText : text;
return (
<section
className="bcd"
data-slot-bg={background ? "" : undefined}
data-tone={tone}
style={{ textAlign: "start", ["--bcd-scrim" as string]: scrim }}
>
<style href="motiva-banner-countdown" precedence="medium">{css}</style>
{background && <div className="bcd-bg">{background}</div>}
<div className="bcd-in">
<span className="bcd-label" data-slot="label">{label}</span>
{shown && <p className="bcd-text" data-slot="text">{shown}</p>}
{left !== 0 && (
<span className="bcd-clock" role="timer" aria-live="off">
{t ? (
<>
<span>{t.d}<small>д</small></span>
<span>{two(t.h)}<small>ч</small></span>
<span>{two(t.m)}<small>м</small></span>
<span>{two(t.s)}<small>с</small></span>
</>
) : (
<span aria-hidden="true">–<small>д</small> ––<small>ч</small> ––<small>м</small> ––<small>с</small></span>
)}
</span>
)}
{ctaLabel && <a className="bcd-cta" href={ctaHref} data-slot="ctaLabel">{ctaLabel}</a>}
</div>
</section>
);
}