デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
署名欄をなぞると、通ったあとに線が描かれ、ゆっくり動かした所は太く、速く動かした所は細くなります。線が1本でもあれば「同意して送信」が押せる色に変わります。「書き直す」を押すと、書いた順を逆にたどって、最後に書いた所から消えていきます。
画ごとに SVG の path を分け、線としてではなく、塗りつぶした輪郭として描いています。stroke-width は線全体で同じ値になるので、途中で太さを変えたければこの形にするしかありません。ポインタの点の列は、隣り合う点の中点どうしを2次ベジェ曲線でつないで中心線にし、その両側へ点ごとの太さの半分ずつ広げて縁を作り、書き始めと書き終わりを半円でふさぎます。
太さは、点と点の距離と時刻の差から速さを求め、速いほど細い目標の太さへ、前の点の太さから3割ずつ近づけています。目標の太さをそのまま使うと、ポインタの点は間隔が不ぞろいなので、点ごとに太さが跳ねて縁がでこぼこになります。速く動かしても間の点が抜けないよう、getCoalescedEvents() で間の点も受け取りました。近づける割合を小さくすると太さの変わり方がゆるやかになり、筆ペンに近い線になります。
消すときは、すべての画の点を書いた順に並べ、残す点の数を power1.in で減らしていきます。輪郭を作る関数には「先頭から何個目の点まで」を渡すだけなので、書いている途中も消えていく途中も、同じ処理で描けます。線がないうちは送信ボタンに aria-disabled を付け、押しても送らないようにしました。送るときは、書いた線の SVG を hidden の入力欄に入れます。キーボードでは署名が書けないので、実際のフォームでは名前を入力する欄も添えてください。
コード
<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>
.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; }
}
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);
});
});