Cookie Notice
Cookie-плашка — третий вариант вида banner: спрашивает, а не зовёт. Две равноправные кнопки без тёмных паттернов, фиксированный слой у нижней кромки, ленивый вопрос к localStorage до первого кадра (сделанный выбор — плашки нет в DOM), уезд вниз по выбору.
Редактируемые поля
Секция «banner». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| text | Текст | text |
| linkLabel | Подпись ссылки | text |
| acceptLabel | Кнопка «принять» | text |
| declineLabel | Кнопка «только нужные» | text |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "Мы используем cookie: необходимые — что… | |
| linkLabel? | string | "Подробнее" | Подпись ссылки на политику (сама ссылка — linkHref). |
| linkHref? | string | "#" | |
| acceptLabel? | string | "Принять все" | |
| declineLabel? | string | "Только необходимые" | |
| remember? | boolean | true | Не запоминать выбор (для витрины). |
| onChoice? | (choice: CookieChoice) => void; | — | Колбэк выбора — сюда вешается реальное включение счётчиков. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cookie-notice.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 { useState } from "react";
const css = `/* ФИКСИРОВАННЫЙ слой у нижней кромки: cookie-плашка живёт поверх страницы,
а не в потоке — иначе её пришлось бы докручивать. z-index ниже модалок,
выше контента */
.ckn { position: fixed; inset: auto 0 0 0; z-index: 40;
padding: var(--ds-4, 16px) var(--ds-gutter, clamp(18px, 5vw, 54px))
max(var(--ds-4, 16px), env(safe-area-inset-bottom));
background: var(--ds-surface, oklch(0.17 0.02 273));
color: var(--ds-fg, oklch(0.95 0.01 265));
font-family: var(--ds-font, system-ui, sans-serif);
border-top: 1px solid var(--ds-border, oklch(0.3 0.02 275));
box-shadow: 0 -12px 34px oklch(0.05 0.02 265 / 0.25);
transition: translate 0.35s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
opacity 0.35s ease; }
.ckn[data-state="closing"] { translate: 0 110%; opacity: 0; }
@media not (prefers-reduced-motion: reduce) {
.ckn[data-state="open"] { animation: ckn-in 0.5s 0.4s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) backwards; }
@keyframes ckn-in { from { translate: 0 110%; } }
}
html[data-shot] .ckn { animation: none; }
.ckn-in { display: flex; flex-wrap: wrap; gap: var(--ds-3, 12px) var(--ds-5, 24px);
align-items: center; max-width: var(--ds-container, 1120px); margin-inline: auto; }
.ckn-text { flex: 1 1 340px; margin: 0;
font-size: var(--ds-text-sm, 0.88rem); line-height: var(--ds-leading-body, 1.5);
color: var(--ds-muted, oklch(0.78 0.02 265)); }
.ckn-text a { color: var(--ds-fg, oklch(0.95 0.01 265)); text-underline-offset: 3px; }
.ckn-text a:hover { color: var(--ds-accent, oklch(0.72 0.16 300)); }
.ckn-row { display: flex; gap: var(--ds-3, 10px); flex-wrap: wrap; }
.ckn-btn { padding: 10px 18px; border-radius: 999px; cursor: pointer;
font: 600 var(--ds-text-sm, 0.88rem) / 1 var(--ds-font, system-ui, sans-serif);
border: 1px solid transparent;
transition: filter 0.2s ease, background-color 0.2s ease; }
.ckn-btn:hover { filter: brightness(1.08); }
.ckn-btn:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.4));
outline-offset: 2px; }
.ckn-all { background: var(--ds-accent, oklch(0.72 0.16 300));
color: var(--ds-accent-fg, oklch(0.14 0.02 275)); }
.ckn-min { background: transparent; color: var(--ds-fg, oklch(0.95 0.01 265));
border-color: var(--ds-border, oklch(0.3 0.02 275)); }
`;
export type CookieChoice = "all" | "necessary";
export interface CookieNoticeProps {
text?: string;
/** Подпись ссылки на политику (сама ссылка — linkHref). */
linkLabel?: string;
linkHref?: string;
acceptLabel?: string;
declineLabel?: string;
/** Ключ в localStorage; сделанный выбор прячет плашку навсегда. */
storageKey?: string;
/** Не запоминать выбор (для витрины). */
remember?: boolean;
/** Колбэк выбора — сюда вешается реальное включение счётчиков. */
onChoice?: (choice: CookieChoice) => void;
}
const read = (key: string) => {
try {
const v = localStorage.getItem(key);
return v === "all" || v === "necessary";
} catch {
return false;
}
};
const write = (key: string, v: CookieChoice) => {
try {
localStorage.setItem(key, v);
} catch {
/* выбор проживёт сессию — это лучше, чем упасть */
}
};
/**
* COOKIE-ПЛАШКА — третий вариант вида banner: два предыдущих зовут и отсчитывают,
* этот СПРАШИВАЕТ. Две равноправные кнопки — «принять все» и «только необходимые»:
* тёмных паттернов с прячущимся отказом здесь нет намеренно, отказ — обычная
* кнопка рядом. Слой фиксированный у нижней кромки (поверх страницы, докручивать
* не нужно), появляется с задержкой — читателю дают начать читать.
*
* Дисциплина хранилища — как у banner-sticky: ленивый инициализатор спрашивает
* localStorage ДО первого кадра, и плашка с уже сделанным выбором в DOM
* не попадает вовсе (никакого мигания). Закрытие — уезд вниз, узел удаляется
* по окончании перехода; под reduced-motion и data-shot — без анимаций.
*/
export function CookieNotice({
text = "Мы используем cookie: необходимые — чтобы сайт работал, аналитические — чтобы понимать, что читают.",
linkLabel = "Подробнее",
linkHref = "#",
acceptLabel = "Принять все",
declineLabel = "Только необходимые",
storageKey = "motiva:cookie-notice",
remember = true,
onChoice,
}: CookieNoticeProps) {
const [state, setState] = useState<"open" | "closing" | "gone">(() =>
remember && typeof window !== "undefined" && read(storageKey) ? "gone" : "open",
);
if (state === "gone") return null;
const choose = (v: CookieChoice) => {
if (remember) write(storageKey, v);
onChoice?.(v);
const reduced =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setState(reduced ? "gone" : "closing");
};
return (
<section
className="ckn"
data-state={state}
aria-label="Использование cookie"
style={{ textAlign: "start" }}
onTransitionEnd={(e) => {
if (e.propertyName === "translate" && state === "closing") setState("gone");
}}
>
<style href="motiva-cookie-notice" precedence="medium">{css}</style>
<div className="ckn-in">
<p className="ckn-text">
{/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
{text && <span data-slot="text">{text}</span>}{" "}
{linkLabel && <a href={linkHref} data-slot="linkLabel">{linkLabel}</a>}
</p>
<div className="ckn-row">
{declineLabel && (
<button type="button" className="ckn-btn ckn-min" onClick={() => choose("necessary")}>
<span data-slot="declineLabel">{declineLabel}</span>
</button>
)}
{acceptLabel && (
<button type="button" className="ckn-btn ckn-all" onClick={() => choose("all")}>
<span data-slot="acceptLabel">{acceptLabel}</span>
</button>
)}
</div>
</div>
</section>
);
}