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 の設定」からいつでも開き直せます。
小さな会社のホームページ相談
このページの裏で動いているものを、同意の状態に合わせて表示しています。
このページで動いているもの
- 必須ログイン・送信の保持動作中
- 機能表示の好みの記憶同意待ち
- 分析アクセスの集計同意待ち
- 広告広告の効果計測同意待ち
Cookie の使い方を選んでください
サイトを動かすのに欠かせないもののほか、好みの記憶・アクセスの集計・広告の計測に Cookie を使います。必須のもの以外は、選ぶまで使いません。
同意のきまり
- 1拒否と許可を同じ重さで「必要なものだけ」と「すべて許可」は同じ大きさ・同じ塗りで隣に並べ、拒否を先に置きます。
- 2選ぶまでは止めておく必須以外のスイッチは最初オフです。保存するまで、分析や広告は読み込みません。
- 3選択は端末に残す次に開いたときはバナーを出しません。項目が変わったときだけ聞き直します。
- 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