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

フォーム

なぞる速さで太さが変わる署名欄。書き直すと逆順に消えていく

デモ

操作:ドラッグ・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

署名欄をなぞると、通ったあとに線が描かれ、ゆっくり動かした所は太く、速く動かした所は細くなります。線が1本でもあれば「同意して送信」が押せる色に変わります。「書き直す」を押すと、書いた順を逆にたどって、最後に書いた所から消えていきます。

画ごとに SVG の path を分け、線としてではなく、塗りつぶした輪郭として描いています。stroke-width は線全体で同じ値になるので、途中で太さを変えたければこの形にするしかありません。ポインタの点の列は、隣り合う点の中点どうしを2次ベジェ曲線でつないで中心線にし、その両側へ点ごとの太さの半分ずつ広げて縁を作り、書き始めと書き終わりを半円でふさぎます。

太さは、点と点の距離と時刻の差から速さを求め、速いほど細い目標の太さへ、前の点の太さから3割ずつ近づけています。目標の太さをそのまま使うと、ポインタの点は間隔が不ぞろいなので、点ごとに太さが跳ねて縁がでこぼこになります。速く動かしても間の点が抜けないよう、getCoalescedEvents() で間の点も受け取りました。近づける割合を小さくすると太さの変わり方がゆるやかになり、筆ペンに近い線になります。

消すときは、すべての画の点を書いた順に並べ、残す点の数を power1.in で減らしていきます。輪郭を作る関数には「先頭から何個目の点まで」を渡すだけなので、書いている途中も消えていく途中も、同じ処理で描けます。線がないうちは送信ボタンに aria-disabled を付け、押しても送らないようにしました。送るときは、書いた線の SVG を hidden の入力欄に入れます。キーボードでは署名が書けないので、実際のフォームでは名前を入力する欄も添えてください。

コード

HTML
<form class="signature" action="/apply/complete/" method="post">
  <fieldset class="signature__field">
    <legend class="signature__label">署名</legend>
    <!-- なぞった線は、1 画ずつ svg の中の path に描く -->
    <div class="signature__pad">
      <span class="signature__mark" aria-hidden="true">×</span>
      <svg class="signature__ink" aria-hidden="true"></svg>
    </div>
    <p class="signature__help">この欄にご署名ください</p>
    <!-- 送信するときに、書いた線の svg をこの欄に入れる -->
    <input type="hidden" name="signature">
    <div class="signature__actions">
      <button class="signature__clear" type="button">書き直す</button>
      <button class="signature__send" type="submit" aria-disabled="true">同意して送信</button>
    </div>
  </fieldset>
</form>

CSS
.signature {
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;     /* 線とボタンの色 */
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgba(38, 38, 38, 0.12);
  --off: #c8c7c3;     /* まだ押せない送信ボタン */
  display: grid;
  justify-items: center;
  padding: 64px 16px;
  background: var(--bg);
  color: var(--ink);
}

.signature__field { width: min(640px, 100%); min-width: 0; margin: 0; padding: 0; border: 0; }
.signature__label { margin-bottom: 20px; padding: 0; font-size: 14px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; }

.signature__pad {
  position: relative;
  height: 240px;
  border-radius: 4px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  overflow: hidden; /* 欄の外まで引いた線は、欄の縁で切る */
  cursor: crosshair;
  touch-action: none; /* 指でなぞっても、ページがスクロールしない */
  user-select: none;
}
/* 書く位置の目安になる下線と「×」 */
.signature__pad::before { content: ""; position: absolute; left: 40px; right: 40px; bottom: 56px; height: 1px; background: var(--line); }
.signature__mark { position: absolute; left: 40px; bottom: 66px; color: var(--mute); font-size: 16px; line-height: 1; }
.signature__ink { position: absolute; inset: 0; width: 100%; height: 100%; fill: var(--ink); }

.signature__help { margin: 16px 0 0; color: var(--mute-small); font-size: 13px; line-height: 1.5; letter-spacing: 0.04em; }

.signature__actions { display: flex; justify-content: flex-end; gap: 16px; margin-top: 28px; }
.signature__actions button {
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
}
.signature__clear { background: none; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); }
.signature__send { background: var(--ink); color: #ffffff; transition: background-color 0.25s; }
.signature__send[aria-disabled="true"] { background: var(--off); cursor: not-allowed; }
.signature__actions button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

@media (max-width: 480px) {
  .signature__pad { height: 200px; }
  .signature__pad::before { left: 24px; right: 24px; bottom: 48px; }
  .signature__mark { left: 24px; bottom: 58px; }
  .signature__actions button { flex: 1; padding: 0 16px; }
}

JavaScript
import { gsap } from "gsap";

const WIDTH = { max: 7.2, min: 2 }; // 線の太さ(px。ゆっくり → 速く)
const SPEED_REF = 620; // この速さ(px/秒)で、いちばん細くなる
const FOLLOW = 0.3; // 太さを目標へ寄せる割合(点ごと)。小さいほど太さの変わり方がゆるやかになる
const STEPS = 4; // 点と点の間の曲線を、何本の直線で近似するか
const NS = "http://www.w3.org/2000/svg";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => v.toFixed(2);

// 点の列の先頭から count 個を、なめらかな線の輪郭(塗りつぶしの path の d)にする。
// 中心線は、隣り合う点の中点どうしを、間の点を制御点にした 2 次ベジェ曲線でつなぎ、両側へ太さの半分ずつずらして縁を作る
function outline(stroke, count) {
  const pts = stroke.slice(0, count);
  if (pts.length === 0) return "";
  if (pts.length === 1) { const { x, y, w } = pts[0], r = w / 2; return `M${f(x - r)} ${f(y)}a${f(r)} ${f(r)} 0 1 0 ${f(2 * r)} 0a${f(r)} ${f(r)} 0 1 0 ${f(-2 * r)} 0Z`; }
  const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, w: (a.w + b.w) / 2 });
  const line = [pts[0]];
  for (let i = 1; i < pts.length - 1; i++) {
    const a = i === 1 ? pts[0] : mid(pts[i - 1], pts[i]), c = pts[i], b = mid(pts[i], pts[i + 1]);
    for (let s = 1; s <= STEPS; s++) {
      const k = s / STEPS, p = (1 - k) ** 2, q = 2 * (1 - k) * k, r = k * k;
      line.push({ x: p * a.x + q * c.x + r * b.x, y: p * a.y + q * c.y + r * b.y, w: p * a.w + q * c.w + r * b.w });
    }
  }
  line.push(pts[pts.length - 1]);
  const left = [], right = [];
  line.forEach((p, i) => { // 進む向きに直交する向きへずらす
    const a = line[Math.max(0, i - 1)], b = line[Math.min(line.length - 1, i + 1)], len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
    const nx = (-(b.y - a.y) / len) * (p.w / 2), ny = ((b.x - a.x) / len) * (p.w / 2);
    left.push(`${f(p.x + nx)} ${f(p.y + ny)}`); right.push(`${f(p.x - nx)} ${f(p.y - ny)}`);
  });
  const end = f(line[line.length - 1].w / 2), start = f(line[0].w / 2); // 書き終わりと書き始めは半円でふさぐ
  return `M${left.join("L")}A${end} ${end} 0 0 0 ${right[right.length - 1]}L${right.reverse().join("L")}A${start} ${start} 0 0 0 ${left[0]}Z`;
}

document.querySelectorAll(".signature").forEach((form) => {
  const pad = form.querySelector(".signature__pad"), ink = form.querySelector(".signature__ink");
  const clear = form.querySelector(".signature__clear"), send = form.querySelector(".signature__send"), field = form.querySelector('input[name="signature"]');
  const strokes = [], paths = []; // 画ごとの点の列(x・y・時刻・太さ)と、その path
  let erasing = false;
  // svg の座標を欄の px にそろえる。書いたあとに大きさが変わったら、線ごと拡大・縮小して保つ
  new ResizeObserver(() => { if (strokes.length === 0) ink.setAttribute("viewBox", `0 0 ${pad.clientWidth} ${pad.clientHeight}`); }).observe(pad);

  // 点を足す。太さは速さから決めた目標へ、前の点の太さから FOLLOW ずつ寄せる(急に太さが変わらない)
  const add = (stroke, event) => {
    const { x, y } = new DOMPoint(event.clientX, event.clientY).matrixTransform(ink.getScreenCTM().inverse());
    const time = event.timeStamp / 1000, prev = stroke[stroke.length - 1];
    let w = (WIDTH.max + WIDTH.min) / 2;
    if (prev) {
      const speed = Math.hypot(x - prev.x, y - prev.y) / Math.max(1 / 240, time - prev.time);
      w = prev.w + (WIDTH.max - (WIDTH.max - WIDTH.min) * Math.min(1, speed / SPEED_REF) - prev.w) * FOLLOW;
    }
    stroke.push({ x, y, time, w });
  };
  const signed = (on) => send.setAttribute("aria-disabled", String(!on));

  pad.addEventListener("pointerdown", (event) => {
    if (erasing || event.button > 0) return;
    pad.setPointerCapture(event.pointerId); // 欄の外へはみ出しても書き続けられる
    const stroke = [];
    strokes.push(stroke);
    paths.push(ink.appendChild(document.createElementNS(NS, "path")));
    add(stroke, event);
    paths[paths.length - 1].setAttribute("d", outline(stroke, 1));
    signed(true);
  });
  pad.addEventListener("pointermove", (event) => {
    if (erasing || !pad.hasPointerCapture(event.pointerId)) return;
    const stroke = strokes[strokes.length - 1];
    for (const e of event.getCoalescedEvents?.() ?? [event]) add(stroke, e); // 速く動かしても点が間引かれない
    paths[paths.length - 1].setAttribute("d", outline(stroke, stroke.length));
  });

  // 書き直す: 書いた順にすべての点を並べ、後ろから減らしていく。消すほど速くなる(power1.in)
  clear.addEventListener("click", () => {
    if (erasing || strokes.length === 0) return;
    const state = { left: strokes.reduce((sum, stroke) => sum + stroke.length, 0) };
    const redraw = () => {
      let rest = Math.round(state.left);
      strokes.forEach((stroke, i) => { const count = Math.min(stroke.length, rest); rest -= count; paths[i].setAttribute("d", outline(stroke, count)); });
    };
    erasing = true; // 消し終えるまで(動きを減らす設定では、作った時点で消し終える)は書けない
    gsap.to(state, {
      left: 0, duration: reduceMotion ? 0 : Math.min(1.2, 0.4 + state.left / 400), ease: "power1.in", onUpdate: redraw,
      onComplete: () => { paths.forEach((path) => path.remove()); strokes.length = paths.length = 0; erasing = false; signed(false); },
    });
  });

  // 線が無いうちは送らない(aria-disabled だけでは送信は止まらない)。送るときは線の svg を欄に入れる
  form.addEventListener("submit", (event) => {
    if (strokes.length === 0 || erasing) { event.preventDefault(); return; }
    field.value = new XMLSerializer().serializeToString(ink);
  });
});

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

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