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

「確認する」のボタンを押すと、はんこが落ちて押され、にじんだ朱の跡が残る

デモ

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

仕様

用途
ボタン
動作
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 で変えられ、はんこの降りる高さと影のずれはその大きさから決まります。見積書や申込書の確認、社内の承認、チェックリストの完了など、「確かに見た」ことを残したい場面に向いています。

コード

HTML
<!-- 確認欄とボタン。跡の 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>

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

JavaScript
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" }); // 拡大したまま残すと、画面の端でページが横にはみ出す
  });
});

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

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