デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ページを開くと、画面の下の外に置いた同意バナーがせり上がり、中のアイコン・文言・ボタンが少し遅れて順に浮かびます。どちらかのボタンを押すと、中身が先に消え、バナーは上の縁から下の縁へ畳まれるように閉じながら、少し沈んで消えます。畳むのは切り抜き(clip-path)の上端を下げる動きで、箱の大きさは変えないので、中の文字や角の丸みが崩れません。
せり上がりは 0.9 秒の expo.out で、最初の 0.3 秒でほぼ出きり、残りの時間でゆっくり止まります。動きの大半を最初に済ませるので、読み始めるころにはバナーが落ち着いています。畳むのは 0.5 秒の power3.inOut で、中身は 0.18 秒で先に消し、畳みながら 16px 沈めます。出るときと消えるときで曲線を変えると、同じ道を戻るより、選び終えた区切りがはっきりします。
clip-path は、今の値から GSAP に読ませて補間すると崩れます。Chrome は inset(0% 0% 0% 0%) のような値を短い形に縮めて返すので、数の並びが行き先と合わず、角の丸みの数まで取り違えて補間され、畳む途中で角の形が変わります。畳んだ量を 1 つの CSS 変数にして clip-path の式に組み込んでおけば、GSAP はその数だけを補間できます。
選んだ結果は localStorage に保存し、次に来たときはバナーを出しません。保存名の末尾に版の数字を付けておくと、ポリシーを変えたときに数字を上げるだけで、全員にもう一度たずねられます。結果はページにイベントで知らせるので、アクセス解析などは「すべて許可」のときだけ読み込めます。足もとの「Cookieの設定」からはいつでも開き直せて、そのときは焦点をバナーのボタンへ移し、選んだら元の場所へ戻します。
画面の隅の小さなカードにするなら、幅を 400px ほどに絞って右下に寄せます。狭い画面では、ボタンを文言の下に横いっぱいに並べます。コーポレートサイトやネットショップ、メディアなど、アクセス解析や広告で Cookie を使うサイトに向いています。
コード
<!-- 同意バナー。hidden のまま置き、まだ選んでいない人にだけスクリプトが出す。
body の最初に置くと、キーボードや読み上げで最初に届く -->
<section class="consent" aria-labelledby="consent-title" hidden>
<svg class="consent__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3a9 9 0 1 0 9 9 3 3 0 0 1-3-3 3 3 0 0 1-3-3 3 3 0 0 1-3-3z" />
<circle cx="8" cy="10.5" r="0.9" /><circle cx="10.5" cy="16" r="0.9" /><circle cx="15.5" cy="14" r="0.9" />
</svg>
<div class="consent__text">
<h2 class="consent__title" id="consent-title">Cookieの使用について</h2>
<p class="consent__desc">
当サイトでは、利便性の向上とアクセス解析のためにCookieを使用しています。詳しくは<a href="/cookie-policy/">Cookieポリシー</a>をご覧ください。
</p>
</div>
<div class="consent__actions">
<button class="consent__button" type="button" data-consent="necessary">必須のみ許可</button>
<button class="consent__button consent__button--fill" type="button" data-consent="all">すべて許可</button>
</div>
</section>
<!-- 選び直す口。フッターなどに置く(押すと、バナーがもう一度せり上がる) -->
<button class="consent-open" type="button" data-consent-open>Cookieの設定</button>
.consent,
.consent-open {
--ink: #eeece6; /* 文字と線 */
--mute: #a8a6a0; /* 説明の文字 */
--surface: #262626; /* バナーの面 */
--line: rgba(238, 236, 230, 0.14); /* 縁の罫線 */
--ground: #111111; /* 主ボタンの文字(地の色) */
--radius: 4px;
}
.consent {
--fold: 0; /* 畳んだ量(0〜100)。スクリプトが動かす */
position: fixed;
right: 24px;
bottom: 24px;
left: 24px;
z-index: 100;
display: flex;
align-items: center;
gap: 24px;
max-width: 1088px;
margin: 0 auto;
padding: 24px 32px;
border-radius: var(--radius);
background-color: var(--surface);
box-shadow: inset 0 0 0 1px var(--line); /* 影は付けず、1px の罫線で縁を取る */
color: var(--ink);
/* 上の縁から下の縁へ畳む。数を 1 つの変数にしておくと、GSAP がそのまま補間できる */
clip-path: inset(calc(var(--fold) * 1%) 0 0 0 round var(--radius));
}
.consent[hidden] { display: none; }
.consent__icon {
flex: none;
width: 32px;
height: 32px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.consent__icon circle { fill: currentColor; }
.consent__text { flex: 1; min-width: 0; }
.consent__title { margin: 0; font-size: 16px; letter-spacing: 0.04em; }
.consent__desc { margin: 8px 0 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }
.consent__desc a { color: var(--ink); text-underline-offset: 4px; white-space: nowrap; }
.consent__actions { display: flex; flex: none; gap: 16px; }
/* 副ボタンは文字の色の線、主ボタンは明るい塗りに地の色の文字 */
.consent__button {
height: 48px;
padding: 0 24px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: none;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
}
.consent__button--fill { box-shadow: none; background-color: var(--ink); color: var(--ground); }
.consent-open {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
}
.consent a:focus-visible,
.consent__button:focus-visible,
.consent-open:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
/* 狭い画面では、ボタンを文言の下に横いっぱいに並べる */
@media (max-width: 720px) {
.consent { right: 16px; bottom: 16px; left: 16px; flex-wrap: wrap; padding: 24px; }
.consent__icon { display: none; }
.consent__actions { width: 100%; }
.consent__button { flex: 1; padding: 0 16px; }
}
import { gsap } from "gsap";
const KEY = "cookie-consent-v1"; // 選んだ結果の保存名。ポリシーを変えたら末尾の数字を上げると、全員にもう一度たずねる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const banner = document.querySelector(".consent");
const items = banner.querySelectorAll(".consent__icon, .consent__text, .consent__button");
let motion = null;
let isOpen = false;
let returnTo = null; // 「Cookieの設定」から開いたときは、閉じたあと焦点をそこへ戻す
// ストレージを切っている環境でも止まらないように、読み書きの失敗は無視する
function load() {
try {
return localStorage.getItem(KEY);
} catch {
return null;
}
}
function save(value) {
try {
localStorage.setItem(KEY, value);
} catch {}
}
// 選んだ結果をほかのスクリプトへ知らせる。アクセス解析などは "all" のときだけ読み込む
// 例: document.addEventListener("cookieconsent", (event) => event.detail === "all" && loadAnalytics());
const announce = (value) => document.dispatchEvent(new CustomEvent("cookieconsent", { detail: value }));
const run = (timeline) => {
motion?.kill();
motion = timeline.timeScale(SPEED);
};
function show(from) {
if (isOpen) return;
isOpen = true;
returnTo = from ?? null;
banner.hidden = false;
gsap.set(banner, { "--fold": 0 });
run(
gsap
.timeline()
// 画面の下の外から、勢いよく出てゆっくり止まる
.fromTo(banner, { y: banner.offsetHeight + 24 }, { y: 0, duration: 0.9, ease: "expo.out" }, 0)
.fromTo(items, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out", stagger: 0.06 }, 0.15)
);
if (from) banner.querySelector("button").focus({ preventScroll: true });
}
function choose(value) {
if (!isOpen) return;
isOpen = false;
save(value);
announce(value);
const hadFocus = banner.contains(document.activeElement);
run(
gsap
.timeline({
onComplete: () => {
banner.hidden = true;
if (hadFocus) returnTo?.focus();
},
})
// 中身を先に消してから、上の縁から下の縁へ畳みながら少し沈める
.to(items, { opacity: 0, duration: 0.18, ease: "power1.in" }, 0)
.to(banner, { "--fold": 100, duration: 0.5, ease: "power3.inOut" }, 0.1)
.to(banner, { y: 16, duration: 0.5, ease: "power2.in" }, 0.1)
);
}
banner.querySelectorAll("[data-consent]").forEach((button) => {
button.addEventListener("click", () => choose(button.dataset.consent));
});
document.querySelectorAll("[data-consent-open]").forEach((button) => {
button.addEventListener("click", () => show(button));
});
// 選んだ結果が保存されていれば、バナーは出さずに結果だけを知らせる
const saved = load();
if (saved) announce(saved);
else show();