本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

利用規約を最後まで読むと、同意のチェックと送信のボタンが順に使えるようになる

デモ

操作:スクロール・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
フォーム
動作
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 でも読み進められます。

チェックそのものを描く動きや、ページ全体の読了バーと違い、枠の中を読んだかどうかで、次の部品を順に使えるようにする作りです。会員登録や申し込みの規約、重要事項の説明、個人情報の取り扱いへの同意に向いています。ただ、読み終えるまで進めない形は急いでいる人には負担になるので、法令や社内の決まりで求められるときに使うのがよいでしょう。動きを減らす設定では、すぐ切り替えます。

コード

HTML
<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>

CSS
.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; }

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

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。