BLK-93Blocks

Cookie Consent Banner (per-category choices)

A cookie consent banner docked to the bottom of the screen. It offers three choices: Necessary only, Allow all and Save my choices. The reject option, Necessary only, sits first, right next to Allow all, at the same size and with the same fill. Choose categories opens switches (button role="switch") for functional, analytics and advertising cookies, while necessary cookies are labelled Always on in text. Everything except necessary cookies starts off and is not used until the visitor saves. The choice is stored in localStorage with a version number, so the banner stays hidden on the next visit, and the Cookie settings button in the footer reopens it with the previous choices loaded. A list of what is running on the page switches between waiting for consent, running and stopped to match the saved choice. It works entirely from the keyboard: after saving, focus moves to the reopen button, and reopening moves it to the banner heading. Escape collapses the category list, or closes a reopened banner without changes. The result is announced once through aria-live. Motion uses only transform and opacity, and with prefers-reduced-motion everything stays still and fully readable. No dependencies, no rounded corners, no small English kickers or trailing arrow links, just hairline rules and whitespace.

Added:
2026-10-09
Dependencies:
None
tags
#cookie #consent #banner #privacy #switch #localstorage #keyboard #accessibility #ui #no-image

Preview

Cookie の同意を、項目ごとに選べるバナー

「必要なものだけ」と「すべて許可」を同じ重さで並べ、分析や広告は1つずつ選べます。選んだ内容は端末に残り、ページ下の「Cookie の設定」からいつでも開き直せます。

First CHサービスお問い合わせ

小さな会社のホームページ相談

このページの裏で動いているものを、同意の状態に合わせて表示しています。

このページで動いているもの

  • 必須ログイン・送信の保持動作中
  • 機能表示の好みの記憶同意待ち
  • 分析アクセスの集計同意待ち
  • 広告広告の効果計測同意待ち
プライバシーポリシー

同意のきまり

  1. 1拒否と許可を同じ重さで「必要なものだけ」と「すべて許可」は同じ大きさ・同じ塗りで隣に並べ、拒否を先に置きます。
  2. 2選ぶまでは止めておく必須以外のスイッチは最初オフです。保存するまで、分析や広告は読み込みません。
  3. 3選択は端末に残す次に開いたときはバナーを出しません。項目が変わったときだけ聞き直します。
  4. 4いつでも開き直せるページ下の「Cookie の設定」から、前回の選択を載せた状態で戻れます。
Tab
ボタンとスイッチを順に選ぶ
Space
スイッチを切り替える
Enter
ボタンを押す
Esc
項目の一覧を畳む・開き直した画面を閉じる
"use client";

import { useId, useRef, useState, useSyncExternalStore, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });

/**
 * Cookie同意バナー(項目ごとに選べる)
 *
 * 差別化: 既存のフォーム・UI部品は入力して送る・押して開くまでで終わる部品で、選んだ状態を端末に残し、
 * 後から同じ画面へ戻って直す「持続する設定」の部品は無かった。本ブロックは同意の保存と開き直しが主役で、
 * 保存内容に応じてページ側で動くものが切り替わるところまでを1枚で見せる。角丸・英字の小見出し・末尾の矢印リンクは置かない。
 *
 * 汎用技法メモ:
 * 1. **保存先は useSyncExternalStore で読む**。localStorage の文字列をそのままスナップショットにし(文字列なので比較が安定)、
 *    同じタブの書き込みは自前のイベント、別タブは storage イベントで知らせる。サーバー側は null(=未保存)を返すので、
 *    静的書き出しでもバナーが出た状態で描かれ、保存済みの端末では読み込み直後に黙って閉じる(動きは操作の後だけ付ける)。
 * 2. **保存形式に版を持たせる**。{ v, via, choices } の v が今の版と違えば未保存とみなして聞き直す。項目を足したら版を上げる。
 * 3. **拒否を許可と同じ重さにする**。「必要なものだけ」と「すべて許可」は同じ大きさ・同じ塗りで隣に並べ、拒否を先に置く。
 *    必須以外のスイッチは最初オフ(選ぶまで使わない)。必須はスイッチにせず「常にオン」と文字で示す(押せない部品を作らない)。
 * 4. **スイッチは button role="switch" aria-checked**。名前と説明は aria-labelledby / aria-describedby で結ぶ。
 *    Space と Enter はボタンの既定で切り替わるので、キー処理を足さない。
 * 5. **フォーカスの行き先を決めておく**。保存したらバナーは inert で閉じるので、開き直しの入口(フッターの「Cookie の設定」)へ移す。
 *    開き直したら見出しへ移す。Esc は項目の一覧を畳み、開き直した画面なら変更せずに閉じる(初回の Esc は同意とみなさない)。
 * 6. 動きは transform と opacity だけ。prefers-reduced-motion では遷移を切り、バナーも一覧も静止したまま全文が読める。
 *
 * 文言・項目・配色はすべて First CH のオリジナル。本番ではバナーを fixed で画面下端に置き、
 * 保存内容に応じて分析・広告のタグを読み込む(読み込む前に必ず保存内容を確かめる)。
 */

type Optional = "functional" | "analytics" | "ads";
type Choices = Record<Optional, boolean>;
type Via = "necessary" | "all" | "custom";
type Saved = { v: number; via: Via; choices: Choices };

const KEY = "fc-cookie-consent";
const VERSION = 1;
const CHANGE = "fc-cookie-consent-change";

const NONE: Choices = { functional: false, analytics: false, ads: false };
const ALL: Choices = { functional: true, analytics: true, ads: true };

const CATEGORIES: { id: "necessary" | Optional; name: string; body: string; runs: string }[] = [
  {
    id: "necessary",
    name: "必須",
    body: "ログインの維持・フォームの送信・この選択の保存に使います。止めるとサイトが動かないため、常にオンです。",
    runs: "ログイン・送信の保持",
  },
  {
    id: "functional",
    name: "機能",
    body: "表示する言語や文字の大きさなど、前回選んだ好みを覚えておきます。",
    runs: "表示の好みの記憶",
  },
  {
    id: "analytics",
    name: "分析",
    body: "どのページがよく読まれたかを、個人を特定しない形で数えます。",
    runs: "アクセスの集計",
  },
  {
    id: "ads",
    name: "広告",
    body: "ほかのサイトで、このサイトに関連する広告を出すための計測をします。",
    runs: "広告の効果計測",
  },
];

const VIA_LABEL: Record<Via, string> = { necessary: "必要なものだけ", all: "すべて許可", custom: "選んで保存" };

const subscribe = (cb: () => void) => {
  window.addEventListener("storage", cb);
  window.addEventListener(CHANGE, cb);
  return () => {
    window.removeEventListener("storage", cb);
    window.removeEventListener(CHANGE, cb);
  };
};
const readRaw = () => {
  try {
    return window.localStorage.getItem(KEY);
  } catch {
    return null;
  }
};
const readServer = () => null;

function parse(raw: string | null): Saved | null {
  if (!raw) return null;
  try {
    const o = JSON.parse(raw);
    if (!o || o.v !== VERSION || !o.choices || !(o.via in VIA_LABEL)) return null;
    return { v: VERSION, via: o.via, choices: { functional: !!o.choices.functional, analytics: !!o.choices.analytics, ads: !!o.choices.ads } };
  } catch {
    return null;
  }
}

const RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";

export default function CookieConsentBanner() {
  const uid = useId();
  const headingId = `${uid}-h`;
  const bannerHeadingId = `${uid}-bh`;
  const panelId = `${uid}-panel`;

  const raw = useSyncExternalStore(subscribe, readRaw, readServer);
  // 保存できない環境(ストレージ拒否・容量不足)でも、このページを開いている間は最新の選択を守る。
  // 書き込みに失敗したときの選択は古い保存内容より新しいので、こちらを優先する
  const [fallback, setFallback] = useState<Saved | null>(null);
  const saved = fallback ?? parse(raw);

  const [reopened, setReopened] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const [draft, setDraft] = useState<Choices>(NONE);
  const [animate, setAnimate] = useState(false);
  const [announce, setAnnounce] = useState("");

  const open = saved === null || reopened;

  const bannerHeading = useRef<HTMLHeadingElement>(null);
  const settingsButton = useRef<HTMLButtonElement>(null);
  const chooseButton = useRef<HTMLButtonElement>(null);
  const firstSwitch = useRef<HTMLButtonElement>(null);

  const say = (msg: string) => {
    setAnnounce("");
    requestAnimationFrame(() => setAnnounce(msg));
  };

  const commit = (via: Via, choices: Choices) => {
    const next: Saved = { v: VERSION, via, choices };
    try {
      window.localStorage.setItem(KEY, JSON.stringify(next));
      setFallback(null);
    } catch {
      // 古い許可が次の訪問で復活しないよう消す。次回はバナーで選び直してもらう
      try {
        window.localStorage.removeItem(KEY);
      } catch {
        /* 消せない環境ではこのページの fallback だけが頼り */
      }
      setFallback(next);
    }
    window.dispatchEvent(new Event(CHANGE));
    setDraft(choices);
    setAnimate(true);
    setReopened(false);
    setExpanded(false);
    const on = CATEGORIES.filter((c) => c.id !== "necessary" && choices[c.id as Optional]).map((c) => c.name);
    say(
      `「${VIA_LABEL[via]}」で保存しました。${on.length ? `${on.join("・")}を使います。` : "必須のもの以外は使いません。"}ページ下の「Cookie の設定」から変えられます。`,
    );
    requestAnimationFrame(() => settingsButton.current?.focus());
  };

  const expand = () => {
    setAnimate(true);
    setExpanded(true);
    requestAnimationFrame(() => firstSwitch.current?.focus());
  };

  const reopen = () => {
    setDraft(saved?.choices ?? NONE);
    setAnimate(true);
    setReopened(true);
    setExpanded(true);
    requestAnimationFrame(() => bannerHeading.current?.focus());
  };

  const cancel = () => {
    setReopened(false);
    setExpanded(false);
    requestAnimationFrame(() => settingsButton.current?.focus());
  };

  const reset = () => {
    try {
      window.localStorage.removeItem(KEY);
    } catch {
      /* 保存できない環境では消すものも無い */
    }
    window.dispatchEvent(new Event(CHANGE));
    setFallback(null);
    setDraft(NONE);
    setAnimate(true);
    setReopened(false);
    setExpanded(false);
    say("保存した選択を消しました。初めて訪れたときのバナーに戻ります。");
    requestAnimationFrame(() => bannerHeading.current?.focus());
  };

  const onBannerKey = (e: KeyboardEvent<HTMLElement>) => {
    if (e.key !== "Escape") return;
    if (saved && reopened) {
      e.preventDefault();
      cancel();
    } else if (expanded) {
      e.preventDefault();
      setExpanded(false);
      requestAnimationFrame(() => chooseButton.current?.focus());
    }
  };

  const solid = `border border-gray-900 bg-gray-900 px-4 py-2.5 text-[13px] font-bold text-white transition-colors hover:border-amber-700 hover:bg-amber-700 motion-reduce:transition-none ${RING}`;
  const outline = `border border-amber-600 bg-white px-4 py-2.5 text-[13px] font-bold text-amber-800 transition-colors hover:bg-amber-50 motion-reduce:transition-none ${RING}`;
  const motion = animate ? "transition-[transform,opacity] duration-300 ease-out motion-reduce:transition-none" : "";

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 広い枠では舞台を左上に置き、見出しと決まりごとは右の列へ寄せる(バナーが主役なので先に目に入れる) */}
      <div className="grid gap-x-8 gap-y-6 @2xl:grid-cols-[minmax(0,1fr)_16rem] @2xl:grid-rows-[auto_1fr]">
        <div className="@2xl:col-start-2 @2xl:row-start-1">
          <h2 id={headingId} className="text-[clamp(1.45rem,3.4vw,2.1rem)] leading-[1.35] font-bold tracking-tight @2xl:text-[1.45rem]">
            Cookie の同意を、項目ごとに選べるバナー
          </h2>
          <p className="mt-4 max-w-2xl border-b border-gray-200 pb-6 text-[14px] leading-[1.9] font-medium text-gray-600 @2xl:mt-3 @2xl:text-[13px] @2xl:leading-[1.85]">
            「必要なものだけ」と「すべて許可」を同じ重さで並べ、分析や広告は1つずつ選べます。選んだ内容は端末に残り、ページ下の「Cookie の設定」からいつでも開き直せます。
          </p>
        </div>

        {/* 見本の舞台。本番ではバナーを fixed で画面下端に出す */}
        <div className="@container/stage relative flex h-[36rem] flex-col overflow-hidden border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#f8f6f2_100%)] @2xl:col-start-1 @2xl:row-span-2 @2xl:row-start-1 @2xl:h-[31rem]">
          <div className="flex h-12 flex-none items-center justify-between border-b border-gray-200 bg-white px-4">
            <span className={`${montserrat.className} text-[13px] font-semibold tracking-wide`}>First CH</span>
            <span className="flex gap-4 text-[11.5px] text-gray-500">
              <span>サービス</span>
              <span className="hidden @sm/stage:inline">制作実績</span>
              <span>お問い合わせ</span>
            </span>
          </div>

          <div className="min-h-0 flex-1 px-4 pt-5 @xl/stage:px-6">
            <p className="text-[16px] leading-[1.5] font-bold">小さな会社のホームページ相談</p>
            <p className="mt-1.5 text-[12px] leading-[1.8] text-gray-500">このページの裏で動いているものを、同意の状態に合わせて表示しています。</p>
            <p className="mt-4 text-[11.5px] font-bold text-gray-500">このページで動いているもの</p>
            <ul className="mt-1.5 border-t border-gray-200">
              {CATEGORIES.map((c) => {
                const running = c.id === "necessary" || (saved ? saved.choices[c.id as Optional] : false);
                const state = c.id === "necessary" ? "動作中" : saved === null ? "同意待ち" : running ? "動作中" : "止めています";
                return (
                  <li key={c.id} className="flex items-center justify-between gap-3 border-b border-gray-200 py-2">
                    <span className="min-w-0 truncate text-[12.5px] text-gray-800">
                      <span className="font-bold">{c.name}</span>
                      <span className="ml-2 text-gray-500">{c.runs}</span>
                    </span>
                    <span className={`flex flex-none items-center gap-1.5 text-[11.5px] font-bold ${running ? "text-amber-800" : "text-gray-400"}`}>
                      <span aria-hidden="true" className={`size-1.5 ${running ? "bg-amber-600" : "border border-gray-400"}`} />
                      {state}
                    </span>
                  </li>
                );
              })}
            </ul>
          </div>

          <footer className="flex flex-none flex-wrap items-center gap-x-5 gap-y-1 border-t border-gray-200 bg-white px-4 py-3 text-[11.5px] text-gray-500 @xl/stage:px-6">
            <span>プライバシーポリシー</span>
            <button
              ref={settingsButton}
              type="button"
              onClick={reopen}
              aria-haspopup="dialog"
              aria-expanded={open && reopened}
              className={`font-bold text-gray-800 underline decoration-gray-300 underline-offset-4 transition-colors hover:decoration-amber-600 motion-reduce:transition-none ${RING}`}
            >
              Cookie の設定
            </button>
            {saved && (
              <span className="text-gray-400">
                前回の保存: <span className="text-gray-600">{VIA_LABEL[saved.via]}</span>
              </span>
            )}
          </footer>

          {/* 同意バナー(下端に固定・ページの操作は塞がない) */}
          <section
            role="dialog"
            aria-modal="false"
            aria-labelledby={bannerHeadingId}
            inert={!open}
            onKeyDown={onBannerKey}
            style={{ transform: open ? "translateY(0)" : "translateY(calc(100% + 1.5rem))", opacity: open ? 1 : 0 }}
            className={`absolute inset-x-0 bottom-0 z-20 max-h-full overflow-y-auto border-t-2 border-amber-600 bg-white shadow-[0_-18px_40px_-24px_rgba(28,25,23,0.4)] ${motion}`}
          >
            <div className="px-4 py-4 @xl/stage:px-6 @xl/stage:py-5">
              <div className="flex items-start justify-between gap-3">
                <h3 id={bannerHeadingId} ref={bannerHeading} tabIndex={-1} className={`text-[15px] leading-[1.5] font-bold ${RING}`}>
                  Cookie の使い方を選んでください
                </h3>
                {saved && reopened && (
                  <button
                    type="button"
                    onClick={cancel}
                    className={`-mt-1 -mr-2 grid size-8 flex-none place-items-center text-[17px] leading-none text-gray-400 transition-colors hover:text-gray-900 motion-reduce:transition-none ${RING}`}
                  >
                    <span className="sr-only">変更せずに閉じる</span>
                    <span aria-hidden="true">×</span>
                  </button>
                )}
              </div>
              <p className="mt-1.5 text-[12.5px] leading-[1.85] text-gray-600">
                サイトを動かすのに欠かせないもののほか、好みの記憶・アクセスの集計・広告の計測に Cookie を使います。必須のもの以外は、選ぶまで使いません。
              </p>

              {expanded && (
                <ul
                  id={panelId}
                  aria-label="Cookie の項目"
                  className="mt-3 border-t border-gray-200 transition-[opacity,transform] duration-300 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
                >
                  {CATEGORIES.map((c, i) => {
                    const nameId = `${uid}-${c.id}-n`;
                    const bodyId = `${uid}-${c.id}-b`;
                    const locked = c.id === "necessary";
                    const on = locked || draft[c.id as Optional];
                    return (
                      <li key={c.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 border-b border-gray-200 py-3">
                        <div className="min-w-0">
                          <p id={nameId} className="text-[13px] leading-[1.6] font-bold">
                            {c.name}
                          </p>
                          <p id={bodyId} className="mt-0.5 text-[12px] leading-[1.75] text-gray-500">
                            {c.body}
                          </p>
                        </div>
                        {locked ? (
                          <span className="pt-0.5 text-[11.5px] font-bold whitespace-nowrap text-gray-500">常にオン</span>
                        ) : (
                          <span className="flex items-center gap-2 pt-0.5">
                            <span aria-hidden="true" className={`w-[2.2em] text-right text-[11.5px] font-bold ${on ? "text-amber-800" : "text-gray-400"}`}>
                              {on ? "オン" : "オフ"}
                            </span>
                            <button
                              ref={i === 1 ? firstSwitch : undefined}
                              type="button"
                              role="switch"
                              aria-checked={on}
                              aria-labelledby={nameId}
                              aria-describedby={bodyId}
                              onClick={() => setDraft((d) => ({ ...d, [c.id]: !d[c.id as Optional] }))}
                              className={`relative h-6 w-11 flex-none border ${on ? "border-amber-600 bg-amber-600" : "border-gray-400 bg-white"} ${RING}`}
                            >
                              <span
                                aria-hidden="true"
                                style={{ transform: on ? "translateX(20px)" : "translateX(0)" }}
                                className={`absolute top-[3px] left-[3px] size-4 transition-transform duration-200 ease-out motion-reduce:transition-none ${on ? "bg-white" : "bg-gray-400"}`}
                              />
                            </button>
                          </span>
                        )}
                      </li>
                    );
                  })}
                </ul>
              )}

              {/* 拒否を先に・許可と同じ塗りと大きさで並べる */}
              <div className="mt-4 grid gap-2 @md/stage:grid-cols-3">
                <button type="button" onClick={() => commit("necessary", NONE)} className={solid}>
                  必要なものだけ
                </button>
                <button type="button" onClick={() => commit("all", ALL)} className={solid}>
                  すべて許可
                </button>
                {expanded ? (
                  <button type="button" onClick={() => commit("custom", draft)} className={outline}>
                    選んで保存
                  </button>
                ) : (
                  <button ref={chooseButton} type="button" onClick={expand} aria-expanded={false} aria-controls={panelId} className={outline}>
                    項目を選ぶ
                  </button>
                )}
              </div>
            </div>
          </section>

          <p role="status" aria-live="polite" aria-atomic="true" className="sr-only">
            {announce}
          </p>
        </div>

        {/* 同意のきまり(実装の挙動をそのまま文章にしたもの) */}
        <div className="min-w-0 @2xl:col-start-2 @2xl:row-start-2">
          <p className="text-[12px] font-bold tracking-widest text-gray-500">同意のきまり</p>
          <ol className="mt-3 border-t border-gray-200">
            {[
              ["拒否と許可を同じ重さで", "「必要なものだけ」と「すべて許可」は同じ大きさ・同じ塗りで隣に並べ、拒否を先に置きます。"],
              ["選ぶまでは止めておく", "必須以外のスイッチは最初オフです。保存するまで、分析や広告は読み込みません。"],
              ["選択は端末に残す", "次に開いたときはバナーを出しません。項目が変わったときだけ聞き直します。"],
              ["いつでも開き直せる", "ページ下の「Cookie の設定」から、前回の選択を載せた状態で戻れます。"],
            ].map(([term, body], i) => (
              <li key={term} className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2 border-b border-gray-200 py-3">
                <span className={`${montserrat.className} text-[12px] font-semibold text-amber-700 tabular-nums`}>{i + 1}</span>
                <span>
                  <span className="block text-[13px] leading-[1.6] font-bold">{term}</span>
                  <span className="mt-1 block text-[12px] leading-[1.75] text-gray-500">{body}</span>
                </span>
              </li>
            ))}
          </ol>
          <dl className="mt-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-[12px] text-gray-600">
            {[
              ["Tab", "ボタンとスイッチを順に選ぶ"],
              ["Space", "スイッチを切り替える"],
              ["Enter", "ボタンを押す"],
              ["Esc", "項目の一覧を畳む・開き直した画面を閉じる"],
            ].map(([k, v]) => (
              <div key={k} className="contents">
                <dt>
                  <kbd className={`${montserrat.className} inline-block min-w-[2.9rem] border border-gray-200 bg-gray-50 px-1.5 py-px text-center text-[10.5px] font-medium text-gray-700`}>
                    {k}
                  </kbd>
                </dt>
                <dd>{v}</dd>
              </div>
            ))}
          </dl>
          <button
            type="button"
            onClick={reset}
            disabled={saved === null && !reopened}
            className={`mt-6 w-full border border-gray-300 bg-white px-3 py-2 text-[12px] font-medium text-gray-700 transition-colors hover:border-gray-900 disabled:cursor-not-allowed disabled:text-gray-300 disabled:hover:border-gray-300 motion-reduce:transition-none ${RING}`}
          >
            見本の保存内容を消して最初から
          </button>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/cookie-consent-banner.json