GODRICH

クッキー同意バナーUI 9選 — コピペで使う同意管理コード

クッキー同意バナーとは、サイトがCookieを使うことを訪問者に知らせ、その選択を受け取るUIで、ここにある9つはバナー・パネル・モーダル・トーストのすべてを実際に押せる状態で作ったものです。そのうち1つは、規約を最後まで読まないとボタンが押せません。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、訪問者が同意画面に出会う順番です。最初は画面下から上がってくる帯、次が項目を個別に選ばせるパネルと、一度閉じた同意をもう一度開く手段です。そのあとは場所と大きさを削ったコーナーカードと1行の帯、許可と拒否を同じ重さで並べる形、同意した瞬間の小さなモーション、最後まで読ませるゲートが続き、締めくくりは保存できたことを伝える確認です。9つとも選択や時刻・回数を localStorage に保存するので、ブラウザを閉じても値は消えません。

番号 どこに出るか 動かすプロパティ
01 · 05 画面いちばん下の横帯 translateY
02 カード内の3行 grid-template-rows · translateX
03 画面中央と左下のボタン scale · opacity
04 右下のコーナー translateY · rotate · scale
06 カード内の2つのボタン opacity · scale
07 中央のアイコンとかけら3つ translate · scale
08 カード上部の進捗バー scaleX
09 右上のトースト translateX · scaleX

01下部固定バナーのスライドアップ

バナーは自分の高さに $sp-2 を足した分だけ下に隠れていて、そこから定位置へ上がってきます。ボタンを押すと同じ値で下へ戻り、押したボタンのラベルは「保存済み」の文言と並んでモックページ上の小さなチップに残ります。

translateY 100%→0$dur-enterradius 16
.cb.is-demo .cb__banner { animation: cb-slide $dur-loop $easing infinite; }

@keyframes cb-slide {
  0%        { transform: translateY(calc(100% + #{$sp-2})); opacity: 0; }
  22%, 72%  { transform: translateY(0); opacity: 1; }
  100%      { transform: translateY(calc(100% + #{$sp-2})); opacity: 0; }
}

02カテゴリー別トグルの詳細パネル

必須・分析・広告の3行がそれぞれスイッチを持ち、見出しを押すと説明欄が 0fr から 1fr へ伸びます。高さを auto にすると transition が効かないため、代わりにグリッドの行サイズを動かしています。

grid 0fr→1fr노브 translateXradius 16
.ct__panel {
  grid-column: 1 / -1;
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.ct__inner { overflow: hidden; }
.ct__row.is-open .ct__panel { grid-template-rows: 1fr; }

.ct__knob { transform: translateX(0); transition: transform $duration $easing; }
.ct__sw.is-on .ct__knob { transform: translateX(16px); }

03同意管理モーダルの再表示

左下の丸いボタンが一度だけふくらんで戻り、押すと背景に幕が降りながらモーダルが .92 から 1 へ広がります。開くときはモーダル内の最初のボタンへ、閉じるときは丸いボタンへフォーカスが戻ります。

scale .92→1백드롭 opacityradius 16
.mo__modal {
  width: min(100%, 264px);
  opacity: 0;
  transform: scale(.92);
  pointer-events: none;
  transition: transform $duration $easing, opacity $duration $ease-out;
}
.mo.is-open .mo__modal { opacity: 1; transform: scale(1); pointer-events: auto; }

.mo__backdrop { background: rgba(23, 20, 26, .44); opacity: 0; pointer-events: none; }
.mo.is-open .mo__backdrop { opacity: 1; pointer-events: auto; }

04初回訪問のコーナーカード

カードが4度傾いた状態で入ってきて角度を整え、目的の3行が0.10秒・0.16秒・0.22秒と遅れて追いかけます。同じキーフレームに遅延だけを変えて渡せば、行ごとにアニメーションを書き直す必要がありません。

rotate 4deg→00.1s 시차radius 16
@keyframes fv-card {
  0%, 100%  { opacity: 0; transform: translateY(16px) rotate(-4deg) scale(.96); }
  18%, 78%  { opacity: 1; transform: translateY(0) rotate(0deg) scale(1); }
}

.fv__card.is-demo { animation: fv-card $duration $easing infinite; }
.fv__card.is-demo .fv__row { animation: fv-row $duration $easing infinite; }
.fv__card.is-demo .fv__row--a { animation-delay: .10s; }
.fv__card.is-demo .fv__row--b { animation-delay: .16s; }
.fv__card.is-demo .fv__row--c { animation-delay: .22s; }

05アイコン1行のミニバナー

高さ44pxの帯は本文をほとんど隠さずに上がってきて、帯の中ではクッキーアイコンだけが左右に傾きます。傾きは -6deg → 6deg → -6deg なので、1周が終わっても巻き戻りが見えません。

높이 44px 고정rotate ±6degradius 12
@keyframes mb-up {
  0%, 100%  { transform: translateY(100%); }
  20%, 82%  { transform: translateY(0); }
}
@keyframes mb-tilt {
  0%   { transform: rotate(-6deg); }
  50%  { transform: rotate(6deg); }
  100% { transform: rotate(-6deg); }
}

.mb__bar.is-demo { animation: mb-up $duration $easing infinite; }
.mb__bar.is-demo .mb__cookie { animation: mb-tilt $duration $ease-out infinite; }

06拒否も一度に — 同等強調のボタン

2つのボタンは同じ2pxの枠線と同じ文字の太さで並び、許可だけが背景を塗りつぶします。フォーカスのリングはボタンごとに ::after で1つずつ持たせ、不透明度で交代させるため、幅が違ってもリングがもう一方のラベルに掛かりません。

outline 2pxscale 0.97 누름radius 12
.ep__pair { display: grid; grid-template-columns: 1fr 1fr; gap: $sp-2; }
.ep__btn {
  border: 2px solid $color;
  background: $stage-paper;
  color: $color;
  font-weight: 700;
}
.ep__btn--accept { background: $color; color: $stage-paper; }
.ep__btn::after {
  content: "";
  position: absolute;
  inset: -6px;
  border: 3px solid $color;
  border-radius: $r-control;
  opacity: 0;
}

07砕けるクッキーアイコン

クッキーが沈んで膨らむ間に、3つのかけらがそれぞれ別の角度へ飛び出して元の位置へ戻ります。自動ループとボタン用の1回再生が同じキーフレームを共有し、繰り返し回数だけを後から上書きしています。

scale .88→1.08부스러기 3개 왕복radius 50%
@keyframes ck-crumb-a {
  0%   { transform: translate(0, 0) scale(.4);        opacity: 0; }
  16%  { transform: translate(-12px, -10px) scale(1); opacity: 1; }
  46%  { transform: translate(-34px, -26px) scale(1); opacity: 1; }
  78%  { transform: translate(-10px, -8px) scale(.7); opacity: .5; }
  100% { transform: translate(0, 0) scale(.4);        opacity: 0; }
}

.ck.is-demo .ck__crumb--a,
.ck.is-burst .ck__crumb--a { animation: ck-crumb-a $duration $easing infinite; }
.ck.is-burst .ck__icon,
.ck.is-burst .ck__crumb { animation-iteration-count: 1; }

08スクロール到達で開く同意ゲート

規約ボックスを最後まで下げないと同意ボタンの disabled は外れず、上の進捗バーは実際の scrollTop の比率を --p で受け取って scaleX で満ちます。自動ループはスクロール位置には触れず、内側の列を上へ流しながら、進捗バーとボタンも同じリズムで動きます。

scaleX 0→1트랙만 translateYradius 12
sgBox.addEventListener('scroll', function () {
  sg.classList.remove('is-demo');
  var max = sgBox.scrollHeight - sgBox.clientHeight;
  var p = max > 0 ? sgBox.scrollTop / max : 1;
  sg.style.setProperty('--p', String(p < 1 ? p : 1));
  if (sgBox.scrollTop + sgBox.clientHeight >= sgBox.scrollHeight - 4) { sgReady(); }
});

09設定保存のトースト

トーストが右から入ってきて、細いタイマー線が右端へ縮む間留まり、同じ方向へ出ていきます。保存した回数と時刻が2行目に付くので、続けて押すと文字が変わるのが見えます。

translateX 120%→0scaleX 1→0 타이머radius 12
@keyframes ts-slide {
  0%   { transform: translateX(120%); }
  14%  { transform: translateX(0); }
  86%  { transform: translateX(0); }
  100% { transform: translateX(120%); }
}
@keyframes ts-timer {
  0%   { transform: scaleX(1); }
  14%  { transform: scaleX(1); }
  86%  { transform: scaleX(0); }
  99%  { transform: scaleX(0); }
  100% { transform: scaleX(1); }
}

どこで壊れるか — 落とし穴

いちばん手こずったのは08番です。480×300では問題ないのに、320×200のレンダリングでだけ文書の高さが208pxになり、8pxはみ出しました。原因は、狭い画面用の補正ブロックで規約ボックスを88pxまでしか縮めていなかったことです。ボックスを76pxに下げ、カードの内側の余白を1目盛り詰めたところ、同じ測定でもはみ出しはなくなりました。バナー系は縦に積む部品が多く、狭い画面で数pxずつ漏れやすいので、目で確かめるより狭いビューポートで実際の文書の高さを測るほうが早いです。2つ目は09番のタイマー線でした。scaleX(1) から scaleX(0) へ縮んだ線をそのままにすると、次の周の先頭で満ちた線に飛んだように見えるため、トーストがすでに画面の外にいる99%の地点でこっそり巻き戻し、0%と100%を同じ状態にしています。3つ目はコードではなく文言でした。9つが1つのデモ用の辞書を共有しているのに、01番の状態チップと04番のボタンが同じ韓国語のキーを使っていて、片方は保存済みの値、もう片方は詳細画面へ進む意味なので、英語の1文字列では両方を賄えませんでした。01番のラベルを言い換えてキーを分け、ようやく3言語がずれなくなりました。この3つを反映した9セットがzipのパスワード r26nqrbj で開き、React版は同じマークアップをCSSモジュールのクラス名に置き換えたものです。なお03番は幕が画面全体を覆う構造のため、レンダリング測定での変化画素が67.8%と出ますが、これは不具合ではなく、モーダルが本来そういうUIだという意味です。

アクセシビリティ

9つとも prefers-reduced-motion: reduce では、繰り返しのアニメーションに加えてトランジションも止めますが、クリックの動作はそのまま残します。01・04・05はバナーとカードが上がりきった状態で、03はモーダルが開いた状態で、08は進捗バーが満ちた状態で止まります。06は左の拒否ボタンにフォーカスリングが立った状態、07はかけらが散った位置、09はトーストが入った位置で止まります。02は残すべき結果がないため畳まれた既定の姿に戻り、スイッチと開閉は人が押したときだけ変わります。操作する要素はすべて button かチェックボックスの input なのでTabキーで届き、スイッチには role="switch"aria-checked、開閉には aria-expanded、03番のモーダルには role="dialog"aria-modal を付けています。09番のトーストには role="status"aria-live="polite" があり、画面を見なくても保存できたことが読み上げられます。条件の詳しい定義は MDN の prefers-reduced-motion にまとまっています。

FAQ

このコードをそのまま貼ればGDPR対応になりますか?

なりません。ここにあるのは画面と操作だけを作ったUIの例で、法律が求めているのは、同意を受け取る前に統計や広告のスクリプトを実行しないことと、同意の記録を残すことです。この9つは選択を localStorage に入れるだけなので、実際のサービスではその値をCookieやサーバーへ移し、スクリプトの読み込みをその値に結び付ける必要があります。

バナーが出ると本文が下にずれてしまいます。なぜですか?

バナーを文書の流れの中に置き、その分だけ高さを増やす作り方になっているためです。モックページに重なる5つ(01・03・04・05・09)はバナーを position: absolute で浮かせ、transform だけで動かすので、バナーが現れても本文のレイアウトは再計算されません。詳しくは MDN の translateY が短くまとまっています。

拒否ボタンを小さくすれば同意率は上がりませんか?

短期的には上がりますが、監督機関が見ているのはまさにそこです。06番が2つのボタンを同じ大きさ・同じ太さで並べているのはそのためで、拒否してもサイトの利用に支障はないという一文をボタンの上に置くところまでが1セットです。

同じ画面の部品をもっと探すなら、モーダルアニメーション9選では開閉の作り方を、トースト通知9選では消える通知の別の形を、トグルスイッチ9選ではスイッチだけを取り出して使えます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。