デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
枠の中の利用規約をスクロールすると、枠の右の細い溝に墨色の線が上から伸び、読んだ割合を示します。最後まで届くと、下の案内が「最後までお読みいただきました」に替わり、「利用規約に同意する」のチェックが灰色から使える色に変わります。チェックを入れると箱が墨色に塗られて白いチェックが描かれ、少し遅れて送信のボタンが押せない灰色から墨色に変わります。読む → 同意する → 送る、の順に 1 段ずつ使えるようになるのが、この作りの要です。
チェックは 0.18 秒で箱を塗り、0.08 秒遅れて 0.3 秒の power2.out で線を描きます。ボタンの色はさらに遅らせ、チェックから 0.25 秒後に 0.35 秒かけて変えるので、目がチェックからボタンへ順に下ります。枠の下の端は 48px ぼかして続きがあることを見せ、読んだ割合が 85% を過ぎたあたりから薄めて、最後まで届くと消します。
最後まで届いたかは、スクロール量が「中身の高さ − 枠の高さ」の 2px 手前を越えたかで見ます。画面の倍率によってはスクロール量が端数になり、ぴったり最後の 1px に届かないことがあるためです。規約が短く枠に収まるときは、はじめから使える状態にします。線や色は、GSAP が 0〜1 で動かす CSS 変数から CSS が作るので、JS に色を書いていません。
チェックは読み終えるまで disabled にし、案内の文を説明として結んで、なぜ使えないかを読み上げに伝えます。送信のボタンは Tab で届くよう aria-disabled にし、押せない理由を説明で添えます。aria-disabled だけでは送信は止まらないので、押せない間はボタンの click で止めます。枠は Tab で入れるようにしてあり、矢印キーや PageDown でも読み進められます。
チェックそのものを描く動きや、ページ全体の読了バーと違い、枠の中を読んだかどうかで、次の部品を順に使えるようにする作りです。会員登録や申し込みの規約、重要事項の説明、個人情報の取り扱いへの同意に向いています。ただ、読み終えるまで進めない形は急いでいる人には負担になるので、法令や社内の決まりで求められるときに使うのがよいでしょう。動きを減らす設定では、すぐ切り替えます。
コード
<form class="terms" action="/signup/" method="post">
<h2 class="terms__title" id="terms-title">利用規約</h2>
<!-- 枠は Tab で入れ、矢印キーや PageDown でも読み進められる。読んだ割合は --read(0〜1)で CSS が線を伸ばす -->
<div class="terms__frame">
<div class="terms__body" role="region" aria-labelledby="terms-title" tabindex="0">
<h3>第1条(目的)</h3>
<p>本規約は、本サービスの利用に関する条件を、利用者と当社との間で定めるものです。</p>
<h3>第2条(登録)</h3>
<p>利用を希望する方は、本規約に同意のうえ、当社の定める方法で登録を申し込むものとします。</p>
<h3>第3条(アカウントの管理)</h3>
<p>利用者は、自己の責任においてパスワードを管理するものとし、第三者に利用させてはなりません。</p>
<h3>第4条(禁止事項)</h3>
<p>利用者は、法令または公序良俗に反する行為、ほかの利用者の権利を侵害する行為、本サービスの運営を妨げる行為をしてはなりません。</p>
<h3>第5条(サービスの変更・停止)</h3>
<p>当社は、事前に通知することなく、本サービスの内容を変更し、または提供を停止することがあります。</p>
<h3>第6条(免責)</h3>
<p>当社は、本サービスの利用により利用者に生じた損害について、当社の故意または重大な過失による場合を除き、責任を負いません。</p>
<h3>第7条(規約の変更)</h3>
<p>当社は、必要と判断した場合、利用者に通知したうえで本規約を変更できるものとします。</p>
<h3>第8条(準拠法・管轄)</h3>
<p>本規約の解釈には日本法を準拠法とし、紛争が生じた場合は、福岡地方裁判所を第一審の専属的合意管轄裁判所とします。</p>
<p class="terms__date">2026年9月30日 制定</p>
</div>
</div>
<p class="terms__status" id="terms-status" aria-live="polite" data-done="最後までお読みいただきました">最後までお読みください</p>
<!-- 読み終えるまでは disabled。送信のボタンは Tab で届くよう aria-disabled にして、押せない理由を説明で添える -->
<label class="terms__agree">
<input class="terms__check" type="checkbox" name="agree" required disabled aria-describedby="terms-status">
<span class="terms__box" aria-hidden="true"><svg viewBox="0 0 20 20"><path d="M5 10.4l3.4 3.4L15 7" pathLength="1"/></svg></span>
利用規約に同意する
</label>
<p class="terms__note" id="terms-note" hidden>利用規約に同意すると押せます</p>
<button class="terms__submit" type="submit" aria-disabled="true" aria-describedby="terms-note">同意して登録する</button>
</form>
.terms {
--ink: #262626;
--mute: #6b6b6b; /* 案内(16px 未満なので小さい補足の色) */
--off-text: #898989; /* 使えない間の「同意する」 */
--off: #c8c7c3; /* 押せない間のボタンの地 */
--line: rgba(38, 38, 38, 0.12);
--surface: #ffffff;
--ready: 0; /* 以下の 4 つは GSAP が 0〜1 で動かす */
--fill: 0;
--draw: 0;
--send: 0;
display: flex;
flex-direction: column;
max-width: 528px;
color: var(--ink);
}
.terms__title { margin: 0 0 20px; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.3; }
/* 枠。右の細い溝(::before)に、読んだ割合の線(::after)を上から伸ばす */
.terms__frame { --read: 0; position: relative; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); }
.terms__frame::before,
.terms__frame::after { content: ""; position: absolute; top: 16px; right: 16px; width: 2px; pointer-events: none; }
.terms__frame::before { bottom: 16px; background-color: var(--line); }
.terms__frame::after { height: calc((100% - 32px) * var(--read)); background-color: var(--ink); }
.terms__body {
height: 270px;
margin: 1px; /* 枠の線の内側で送る(文字が線に重ならない) */
box-sizing: border-box;
padding: 20px 56px 20px 24px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none; /* 読んだ割合の線がスクロールバーの代わり */
font-size: 14px;
letter-spacing: 0.04em;
line-height: 1.8;
/* 下の端をぼかして続きがあることを見せ、最後まで届くと消す */
mask-image: linear-gradient(#000 calc(100% - 48px), rgb(0 0 0 / clamp(0, calc(var(--read) * 6.67 - 5.67), 1)));
}
.terms__body::-webkit-scrollbar { display: none; }
.terms__body:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.terms__body h3 { margin: 16px 0 4px; font-size: 14px; }
.terms__body h3:first-child { margin-top: 0; }
.terms__body p { margin: 0; }
.terms__body .terms__date { margin-top: 16px; color: var(--mute); text-align: right; }
.terms__status { margin: 12px 0 0; color: var(--mute); font-size: 13px; letter-spacing: 0.04em; line-height: 1.5; }
.terms__status.is-done { color: var(--ink); font-weight: 600; }
/* 本物のチェックは箱に重ねて透かす。色は --ready(使える)と --fill(入っている)から作る */
.terms__agree {
position: relative;
display: flex;
align-self: flex-start;
align-items: center;
gap: 12px;
margin-top: 24px;
padding: 4px 24px 4px 0;
color: color-mix(in srgb, var(--ink) calc(var(--ready) * 100%), var(--off-text));
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.5;
}
.terms__check { position: absolute; left: 0; width: 20px; height: 20px; margin: 0; opacity: 0; cursor: pointer; }
.terms__check:disabled { cursor: not-allowed; }
.terms__box {
flex: none;
width: 20px;
height: 20px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--ink) calc(var(--fill) * 100%), var(--surface));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) calc(20% + var(--ready) * 80%), transparent);
}
.terms__check:focus-visible + .terms__box { outline: 2px solid var(--ink); outline-offset: 3px; }
.terms__box svg { display: block; width: 100%; height: 100%; fill: none; stroke: #ffffff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* 丸い線端が描く前に点で残らないよう、描き始めるまでは透かす */
.terms__box path { stroke-dasharray: 1 1; stroke-dashoffset: calc(1 - var(--draw)); opacity: clamp(0, calc(var(--draw) * 1000), 1); }
.terms__submit {
margin-top: 24px;
height: 48px;
border: 0;
border-radius: 999px;
background-color: color-mix(in srgb, var(--ink) calc(var(--send) * 100%), var(--off));
color: #ffffff;
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.terms__submit[aria-disabled="true"] { cursor: not-allowed; }
.terms__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const d = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、すぐ切り替える
document.querySelectorAll(".terms").forEach((form) => {
const frame = form.querySelector(".terms__frame");
const body = form.querySelector(".terms__body");
const status = form.querySelector(".terms__status");
const agree = form.querySelector(".terms__check");
const submit = form.querySelector(".terms__submit");
let tl;
// 最後まで読んだら、同意のチェックを使えるようにする。案内は前の文を消しきってから替える
function unlock() {
agree.disabled = false;
gsap.to(form, { "--ready": 1, duration: d(0.3), ease: "power2.out" });
gsap.timeline()
.to(status, { opacity: 0, duration: d(0.15) })
.add(() => {
status.textContent = status.dataset.done; // aria-live で読み上げにも届く
status.classList.add("is-done");
})
.to(status, { opacity: 1, duration: d(0.2) });
}
function measure() {
const rest = body.scrollHeight - body.clientHeight;
frame.style.setProperty("--read", rest > 0 ? Math.min(1, body.scrollTop / rest) : 1);
// 端数で最後の 1px に届かないことがあるので、2px の手前で読み終えたとみなす
if (agree.disabled && body.scrollTop >= rest - 2) unlock();
}
body.addEventListener("scroll", measure, { passive: true });
measure(); // 規約が短く枠に収まるときは、はじめから使える
// 入れると箱を塗ってからチェックを描き、少し遅れてボタンを押せる色にする。外すときは同時に戻す
agree.addEventListener("change", () => {
const on = agree.checked;
submit.setAttribute("aria-disabled", String(!on));
tl?.kill(); // 続けて押したとき、前の遅れて始まるトゥイーンが後から効かないように
tl = gsap.timeline()
.to(form, { "--fill": Number(on), duration: d(0.18), ease: "power2.out" }, 0)
.to(form, { "--draw": Number(on), duration: d(0.3), ease: "power2.out" }, on ? d(0.08) : 0)
.to(form, { "--send": Number(on), duration: d(0.35), ease: "power2.out" }, on ? d(0.25) : 0);
});
// aria-disabled だけでは送信は止まらない。押せない間は、ボタンの click で止める(欄で Enter を押したときもここを通る)
submit.addEventListener("click", (e) => {
if (submit.getAttribute("aria-disabled") === "true") e.preventDefault();
});
});