デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
書類の確認欄の「確認する」ボタンです。押すと、真上から丸いはんこ(上から見た木の頭)が影と重なりながら降りてきて確認欄に押され、わずかに広がってから持ち上がって消えます。紙には「確認」の朱の跡が、かすれとにじみを持って残ります。はんこが離れたあとで、ボタンは白地に線の形の「確認済み」に変わり、チェックが描かれます。
はんこは 0.42 秒の power2.in で、1.6 倍の大きさから紙の上の大きさへ縮みながら降ります。あとほど速いので、落ちて当たったように見えます。影は高さに合わせて右下へずらし、はんこが紙に着くと真下で重なります。着いたら 0.07 秒で 1.06 倍に広げて 0.16 秒で戻し、押しつけてつぶれた感じを出します。持ち上げるのは着いてから 0.22 秒あとの 0.4 秒で、1.45 倍にしながら薄くします。
跡は SVG で、フィルターは使っていません。濃さのむらは、輪と字を斜めの linearGradient(濃さ 74% → 100%)で塗って作り、かすれは紙の色の小さな点を輪と字の上に 56 個散らして作ります。にじみは、同じ形を下に 20% の濃さで敷き、押したあと 0.8 秒かけて線を太くして、朱が紙にしみていくように見せます。グラデーションは id で指すので、同じページに 2 つ置いても混ざらないよう、スクリプトで id に番号を足します。
押したあとに disabled を付けると焦点がボタンから外れるので、2 度押せないことは aria-disabled で伝え、押下は click の受け口で無視します。読み上げる名前は「確認済み」の文言の側に移し、見えない status の領域に「確認印を押しました。」と書きます。動きを減らす設定では、はんこを出さずに、跡とボタンだけを一瞬で切り替えます。
押した手応えをボタン自体の伸び縮みで見せるボタンや、押すと弾けて粒が飛ぶアイコンとは違い、押した結果を紙に残る跡として別の場所に見せる動きです。跡の色は --seal、確認欄の大きさは --box で変えられ、はんこの降りる高さと影のずれはその大きさから決まります。見積書や申込書の確認、社内の承認、チェックリストの完了など、「確かに見た」ことを残したい場面に向いています。
コード
<!-- 確認欄とボタン。跡の SVG は初めから置いておき、押すまで透明にしておく(にじみの層とかすれの点は JS が足す) -->
<div class="stamp-confirm">
<p class="stamp-confirm__help">内容に問題がなければ、確認してください。</p>
<button class="stamp-confirm__button" type="button">
<span class="stamp-confirm__label">確認する</span>
<span class="stamp-confirm__label stamp-confirm__label--done" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1" /></svg>確認済み
</span>
</button>
<div class="stamp-confirm__box">
<span class="stamp-confirm__caption">確認印</span>
<svg class="stamp-confirm__seal" viewBox="-50 -50 100 100" aria-hidden="true">
<defs>
<linearGradient gradientUnits="userSpaceOnUse" x1="-40" y1="-46" x2="38" y2="48">
<stop offset="0" stop-opacity="0.74" /><stop offset="0.55" stop-opacity="0.94" /><stop offset="1" />
</linearGradient>
</defs>
<g transform="rotate(-8)">
<g class="stamp-confirm__ink"><circle r="46" /><text y="-16">確</text><text y="19">認</text></g>
</g>
</svg>
<span class="stamp-confirm__tool" aria-hidden="true"><span class="stamp-confirm__shadow"></span><span class="stamp-confirm__cap"></span></span>
</div>
<p class="stamp-confirm__status" role="status" data-done="確認印を押しました。"></p>
</div>
.stamp-confirm {
--ink: #262626; /* ボタンの地・押したあとの線と文字 */
--surface: #ffffff; /* 紙と、押したあとのボタンの地 */
--line: rgba(38, 38, 38, 0.12);
--note: #6b6b6b;
--seal: #b27771; /* 朱の跡 */
--cap: #e1d2c1; /* はんこの頭 */
--cap-rim: #d2b999;
--cap-mark: #A28767;
--box: 96px; /* 確認欄の大きさ。跡とはんこはこの比で決まる */
--done: 0; /* 0 = 確認する、1 = 確認済み。JS が動かす */
--check: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 32px 24px;
padding: 24px 24px 0; /* 上は確認欄の見出しの分、左右は降りてくるはんこが外へはみ出さない分 */
}
.stamp-confirm__help { flex: 1 1 16em; margin: 0; color: var(--note); font-size: 14px; letter-spacing: 0.04em; }
.stamp-confirm__button {
display: grid;
height: 48px;
padding: 0 32px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--surface) calc(var(--done) * 100%), var(--ink));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) calc(var(--done) * 100%), transparent);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
}
.stamp-confirm__button[aria-disabled="true"] { cursor: default; }
.stamp-confirm__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 2 つの文言を同じ升に重ねる。幅は長いほうで決まり、入れ替わっても変わらない */
.stamp-confirm__label { grid-area: 1 / 1; display: flex; justify-content: center; align-items: center; gap: 6px; color: #ffffff; }
.stamp-confirm__label--done { color: var(--ink); opacity: 0; }
.stamp-confirm__label svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* チェックは --check で描く。丸い線端の点は、描き始めるまで消しておく */
.stamp-confirm__label path {
stroke-dasharray: 1 1;
stroke-dashoffset: calc(1 - var(--check));
opacity: clamp(0, calc(var(--check) * 1000), 1);
}
.stamp-confirm__box {
position: relative;
flex: none;
width: var(--box);
height: var(--box);
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.stamp-confirm__caption { position: absolute; left: 0; bottom: calc(100% + 8px); color: var(--note); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; }
.stamp-confirm__seal {
--bleed: 0; /* にじみの広がり。押したあとに JS が 0 → 1 と動かす */
position: absolute;
inset: 12%;
overflow: visible;
opacity: 0;
}
.stamp-confirm__seal stop { stop-color: var(--seal); }
.stamp-confirm__seal text { font-size: 34px; font-weight: 700; text-anchor: middle; dominant-baseline: central; }
.stamp-confirm__ink circle { fill: none; stroke-width: 5; }
.stamp-confirm__bleed { opacity: 0.2; fill: var(--seal); stroke: var(--seal); stroke-linejoin: round; stroke-width: calc(2.8 * var(--bleed)); }
.stamp-confirm__bleed circle { fill: none; stroke-width: calc(5 + 2.8 * var(--bleed)); }
.stamp-confirm__dots { fill: var(--surface); }
/* はんこ(上から見た木の頭)と、紙の上の影 */
.stamp-confirm__tool { position: absolute; inset: 6%; visibility: hidden; pointer-events: none; }
.stamp-confirm__shadow, .stamp-confirm__cap { position: absolute; inset: 0; border-radius: 50%; }
.stamp-confirm__shadow { background: rgba(38, 38, 38, 0.16); }
.stamp-confirm__cap { background: var(--cap); box-shadow: inset 0 0 0 calc(var(--box) * 0.06) var(--cap-rim); }
.stamp-confirm__cap::after { content: ""; position: absolute; left: 50%; top: 13%; width: 8%; height: 8%; margin-left: -4%; border-radius: 50%; background: var(--cap-mark); }
.stamp-confirm__status { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 0 : 1; // 動きを減らす設定では、はんこを出さずに跡とボタンだけを切り替える
const SVG = "http://www.w3.org/2000/svg";
let count = 0;
document.querySelectorAll(".stamp-confirm").forEach((root) => {
const button = root.querySelector(".stamp-confirm__button");
const [ready, done] = button.querySelectorAll(".stamp-confirm__label");
const box = root.querySelector(".stamp-confirm__box");
const seal = root.querySelector(".stamp-confirm__seal");
const ink = seal.querySelector(".stamp-confirm__ink");
const tool = root.querySelector(".stamp-confirm__tool");
const cap = root.querySelector(".stamp-confirm__cap");
const shadow = root.querySelector(".stamp-confirm__shadow");
const status = root.querySelector(".stamp-confirm__status");
// 濃さのむら: 斜めのグラデーションで塗る。同じページに 2 つ置いても混ざらないよう、id に番号を足す
const gradient = seal.querySelector("linearGradient");
gradient.id = `stamp-ink-${++count}`;
ink.setAttribute("fill", `url(#${gradient.id})`);
ink.querySelector("circle").setAttribute("stroke", `url(#${gradient.id})`);
// にじみ: 同じ形を下に敷き、線を太くして薄く見せる
const bleed = ink.cloneNode(true);
bleed.setAttribute("class", "stamp-confirm__bleed");
ink.before(bleed);
// かすれ: 紙の色の小さな点を、輪と字の上に散らす
const dots = document.createElementNS(SVG, "g");
dots.setAttribute("class", "stamp-confirm__dots");
for (let i = 0; i < 56; i++) {
const onRing = i < 26;
const a = Math.random() * Math.PI * 2;
const r = 43.5 + Math.random() * 5;
const dot = document.createElementNS(SVG, "circle");
dot.setAttribute("cx", onRing ? Math.cos(a) * r : (Math.random() - 0.5) * 36);
dot.setAttribute("cy", onRing ? Math.sin(a) * r : (Math.random() - 0.5) * 74);
dot.setAttribute("r", 0.5 + Math.random() * 1.2);
dots.append(dot);
}
ink.after(dots);
const showDone = () => {
ready.setAttribute("aria-hidden", "true");
done.removeAttribute("aria-hidden");
status.textContent = status.dataset.done;
gsap.timeline()
.to(button, { "--done": 1, duration: 0.22 * SPEED, ease: "none" }, 0)
.to(ready, { opacity: 0, duration: 0.08 * SPEED, ease: "none" }, 0)
.to(done, { opacity: 1, duration: 0.14 * SPEED, ease: "none" }, 0.08 * SPEED)
.to(button, { "--check": 1, duration: 0.14 * SPEED, ease: "power2.out" }, 0.1 * SPEED);
};
button.addEventListener("click", () => {
// 2 度押させない。disabled にすると焦点が外れるので、aria-disabled で伝える
if (button.getAttribute("aria-disabled") === "true") return;
button.setAttribute("aria-disabled", "true");
// 降りる高さ・持ち上がる高さ・影のずれは、確認欄の大きさから決める
const size = box.offsetHeight;
const high = { x: size * 0.21, y: size * 0.26, scale: 1.1, opacity: 0.5 };
const touch = 0.42 * SPEED;
gsap.timeline()
.fromTo(tool, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.12 * SPEED, ease: "none" }, 0)
// 落ちる動きは、あとほど速い(power2.in)。影は高さに合わせて、はんこの真下へ寄る
.fromTo(cap, { y: -size * 1.18, scale: 1.6 }, { y: 0, scale: 1, duration: touch, ease: "power2.in" }, 0)
.fromTo(shadow, high, { x: 0, y: 0, scale: 1, opacity: 1, duration: touch, ease: "power2.in" }, 0)
// 紙に着いたら跡を置き、わずかに広がって(つぶれて)戻る。朱は 0.8 秒かけてにじむ
.set(seal, { opacity: 1 }, touch)
.to(cap, { scale: 1.06, duration: 0.07 * SPEED, ease: "power1.out" }, touch)
.to(cap, { scale: 1, duration: 0.16 * SPEED, ease: "power2.out" }, touch + 0.07 * SPEED)
.fromTo(seal, { "--bleed": 0 }, { "--bleed": 1, duration: 0.8 * SPEED, ease: "power2.out" }, touch)
// 持ち上げて消す
.to(cap, { y: -size * 0.88, scale: 1.45, duration: 0.4 * SPEED, ease: "power2.in" }, touch + 0.22 * SPEED)
.to(shadow, { ...high, duration: 0.4 * SPEED, ease: "power2.in" }, touch + 0.22 * SPEED)
.to(tool, { autoAlpha: 0, duration: 0.18 * SPEED, ease: "none" }, touch + 0.44 * SPEED)
.add(showDone, touch + 0.5 * SPEED)
.set([cap, shadow], { clearProps: "transform,opacity" }); // 拡大したまま残すと、画面の端でページが横にはみ出す
});
});