BLK-93Blocks

Cookie同意バナー(項目ごとに選べる)

画面の下端に出す Cookie 同意バナー。「必要なものだけ」「すべて許可」「選んで保存」の3択で、拒否にあたる「必要なものだけ」は「すべて許可」と同じ大きさ・同じ塗りで隣に並べ、先に置く。「項目を選ぶ」で機能・分析・広告のスイッチ(button role="switch")が開き、必須は「常にオン」と文字で示す。必須以外は最初オフで、保存するまで使わない。選択は版つきで localStorage に保存し、次に開いたときはバナーを出さず、ページ下の「Cookie の設定」から前回の選択を載せた状態で開き直せる。保存内容に応じてページ側の「動いているもの」の一覧が同意待ち・動作中・止めていますに切り替わる。キーボードだけで操作が完結し、保存後はフォーカスが開き直しの入口へ、開き直すと見出しへ移る。Esc は項目の一覧を畳み、開き直した画面なら変更せずに閉じる。結果は aria-live で1回だけ読み上げる。動きは transform と opacity だけで、prefers-reduced-motion では静止したまま全文が読める。依存ライブラリなし。角丸・英字の小見出し・末尾の矢印リンクは使わず、hairline 罫線と余白で組んでいる。

追加日:
2026-10-09
依存:
なし
tags
#cookie #consent #banner #privacy #switch #localstorage #keyboard #accessibility #ui #no-image

プレビュー

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>
  );
}

shadcn CLI でプロジェクトに追加

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