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

Cookie Notice

Cookie-плашка — третий вариант вида banner: спрашивает, а не зовёт. Две равноправные кнопки без тёмных паттернов, фиксированный слой у нижней кромки, ленивый вопрос к localStorage до первого кадра (сделанный выбор — плашки нет в DOM), уезд вниз по выбору.

cssclickrevealbasic~3 КБreduced-motion ✓
↗

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

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

ПропЧто этоТип
textТекстtext
linkLabelПодпись ссылкиtext
acceptLabelКнопка «принять»text
declineLabelКнопка «только нужные»text

Все пропсы

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

ПропТипПо умолчаниюЧто это
text?string"Мы используем cookie: необходимые — что…
linkLabel?string"Подробнее"Подпись ссылки на политику (сама ссылка — linkHref).
linkHref?string"#"
acceptLabel?string"Принять все"
declineLabel?string"Только необходимые"
remember?booleantrueНе запоминать выбор (для витрины).
onChoice?(choice: CookieChoice) => void;—Колбэк выбора — сюда вешается реальное включение счётчиков.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/cookie-notice.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";

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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0