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

カード

クーポンの切り取り部分を引くと、ミシン目から裂けて離れていく

デモ

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

仕様

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

しくみと調整

クーポンの右にある切り取り部分をつかんで右へ引くと、ミシン目の下の端を支点に少しずつ傾いて、上のほうからすき間が開いていきます。90px引いたところで切り離され、回りながら右下へ落ちていき、カードの下に「クーポンを受け取りました」と表示されます。途中で手を離したときは、0.4秒の power3.out で元の位置へ戻ります。

ミシン目に画像は使わず、CSS の mask で抜いています。左の部品の右の縁と、切り取り部分の左の縁に、同じ間隔の半円の穴を radial-gradient で並べ、上下の角の切り欠きと mask-composite で重ねました。並んでいるうちは左右の半円が合わさって丸い穴の列になり、離れるとそれぞれの縁がぎざぎざの切り口になります。穴の間隔はカードの高さを割り切れる値にしてください。割り切れないと、上と下とで穴の欠け方がそろいません。

傾きは引いた量に比例させ、支点を左下に置いたので、すき間は上ほど広く、下へ行くほど細いくさび形になります。紙が上から裂けていく途中に見えるのは、この形のおかげです。指に付いてくるように横へずらすだけだと、下の端までいっぺんに離れてしまい、破れていく感じは生まれません。切り離したあとは横を減速、縦を加速させ、消えるのは終わり近くにまとめました。

切り取り部分は button 要素です。Enter や Space で押されたとき(click の detail が 0)は、引いて切り離す動きをそのまま再生して受け取ったことにします。マウスでクリックしただけなら少し傾けて戻し、引いて使うものだと示します。受け取るとボタンが消えるので、フォーカスは知らせの行へ移し、その文を role="status" でスクリーンリーダーに伝えています。

コード

HTML
<div class="coupon">
  <div class="coupon__ticket">
    <div class="coupon__main">
      <p class="coupon__head"><span class="coupon__shop">ベーカリー こむぎ</span><span class="coupon__eyebrow">COUPON</span></p>
      <p class="coupon__value">¥500<span>OFF</span></p>
      <p class="coupon__terms">3,000円以上の<wbr>お買い上げで<wbr>使えます</p>
      <p class="coupon__limit"><span>有効期限</span>11月30日まで</p>
    </div>
    <!-- マウスや指では右へ引いて切り離す。Enter・Space では、引いて切り離す動きをそのまま流す -->
    <button class="coupon__stub" type="button">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
      引いて受け取る
    </button>
  </div>
  <!-- 受け取ったときに、data-done の文を入れて読み上げる(最初から入れておくと読み上げに届かない) -->
  <p class="coupon__status" role="status" tabindex="-1" data-done="クーポンを受け取りました"></p>
</div>

CSS
.coupon {
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --success: #3f7352;    /* 受け取りの知らせ(16px 未満の文字にも使える成功の色) */
  --stub: 180px;         /* 右の切り取り部分の幅 */
  --hole: 3px;           /* ミシン目の穴の半径 */
  --pitch: 12px;         /* ミシン目の穴の間隔。カードの高さを割り切れる値にすると、上下の穴がそろう */
  --notch: 12px;         /* ミシン目の上下の、半円の切り欠きの半径 */
  display: grid;
  justify-items: center;
  gap: 32px;
  padding: 64px 16px;
  overflow-x: clip; /* 飛んでいく切り取り部分で、ページが横にはみ出さない */
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.coupon__ticket { display: grid; grid-template-columns: minmax(0, 1fr) var(--stub); width: min(720px, 100%); height: 240px; }

/* 2 つの部品の向かい合う縁に、穴を半分ずつ抜く。並んでいる間は丸い穴の列に、離れるとぎざぎざの切り口に見える。
   上下の角の切り欠きを足し合わせ(add)、穴の列と重なる所だけを残す(intersect) */
.coupon__main,
.coupon__stub {
  --side: 100%;
  mask:
    radial-gradient(circle at var(--side) 50%, #0000 var(--hole), #000 calc(var(--hole) + 0.5px)) 0 0 / 100% var(--pitch) repeat-y,
    radial-gradient(circle at var(--side) 0, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) 0 0 / 100% 51% no-repeat,
    radial-gradient(circle at var(--side) 100%, #0000 var(--notch), #000 calc(var(--notch) + 0.5px)) 0 100% / 100% 51% no-repeat;
  mask-composite: intersect, add;
}

.coupon__main { min-width: 0; padding: 32px 40px; border-radius: 4px 0 0 4px; background: var(--surface); }
.coupon__head { display: flex; margin: 0; justify-content: space-between; align-items: center; gap: 16px; line-height: 1; }
.coupon__shop { font-size: 15px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
.coupon__eyebrow { color: var(--mute-small); font-size: 12px; font-weight: 700; letter-spacing: 0.14em; }
.coupon__value { margin: 20px 0 0; font-size: clamp(44px, 10vw, 64px); font-weight: 800; line-height: 1; letter-spacing: -0.02em; white-space: nowrap; }
.coupon__value span { margin-left: 0.16em; font-size: 44%; letter-spacing: 0; }
.coupon__terms { margin: 18px 0 0; color: var(--mute-small); font-size: 14px; line-height: 1.5; letter-spacing: 0.04em; word-break: keep-all; } /* <wbr> の所でだけ折り返す */
.coupon__limit { margin: 10px 0 0; font-size: 13px; line-height: 1.5; letter-spacing: 0.04em; }
.coupon__limit span { margin-right: 8px; color: var(--mute-small); }

/* 切り取り部分。ミシン目の下の端(左下)を支点に傾ける */
.coupon__stub {
  --side: 0%;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 14px;
  border: 0;
  border-radius: 0 4px 4px 0;
  background: var(--ink);
  color: var(--bg);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
  transform-origin: 0 100%;
  cursor: grab;
  touch-action: none; /* タッチで引いても、ページが横にスクロールしない */
  user-select: none;
}
.coupon__stub:active { cursor: grabbing; }
.coupon__stub svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
/* mask で外側が切れるので、フォーカスの線は内側に描く */
.coupon__stub:focus-visible { outline: 2px solid var(--bg); outline-offset: -10px; }
.coupon__stub[hidden] { display: none; }

.coupon__status { display: flex; align-items: center; gap: 10px; min-height: 22px; margin: 0; color: var(--success); font-size: 15px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; outline: none; }
.coupon__status:not(:empty)::before {
  content: "";
  width: 22px;
  height: 22px;
  background: currentColor;
  /* 丸からチェックの形を抜いたアイコン */
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cmask id='m'%3E%3Ccircle cx='12' cy='12' r='11' fill='white'/%3E%3Cpath d='M7.5 12.5l3 3 6-6.5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/mask%3E%3Ccircle cx='12' cy='12' r='11' mask='url(%23m)'/%3E%3C/svg%3E") center / contain no-repeat;
}

@media (max-width: 560px) {
  .coupon { --stub: 120px; }
  .coupon__stub { font-size: 13px; letter-spacing: 0.02em; }
  .coupon__ticket { height: 264px; }
  .coupon__main { padding: 28px 20px; }
  .coupon__head { flex-direction: column-reverse; align-items: flex-start; gap: 10px; }
}

JavaScript
import { gsap } from "gsap";

const THRESHOLD = 90; // 切り離すまでに引く量(px)
const TILT = 15; // しきい値まで引いたときの傾き(°)
const FLY = { time: 0.55, x: 200, y: 110, turn: 20 }; // 切り離したあと飛んでいく秒数・横と縦の量(px)・足す回転(°)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const flyOut = gsap.parseEase("power1.out");

document.querySelectorAll(".coupon").forEach((coupon) => {
  const stub = coupon.querySelector(".coupon__stub"), status = coupon.querySelector(".coupon__status");
  const state = { pull: 0, fly: 0 }; // 引いた量(0〜1)と、飛んでいく進み(0〜1)
  let from = 0, start = 0, moved = false, torn = false, tween = null;

  // 2 つの値から、切り取り部分の傾き・位置・濃さを描く。横は減速、縦は加速して、落ちながら離れていく
  const draw = () => {
    const { pull, fly } = state;
    stub.style.transform = `translate(${FLY.x * flyOut(fly)}px, ${FLY.y * fly * fly}px) rotate(${TILT * pull + FLY.turn * fly}deg)`;
    stub.style.opacity = String(1 - fly ** 3); // 終わり近くで一気に消える
  };
  const to = (vars) => {
    tween?.kill();
    tween = gsap.to(state, { ...vars, duration: reduceMotion ? 0 : vars.duration, onUpdate: draw });
  };

  const tear = () => {
    torn = true;
    state.pull = 1;
    const focused = document.activeElement === stub;
    to({ fly: 1, duration: FLY.time, ease: "none", onComplete: () => {
      stub.hidden = true;
      if (focused) status.focus({ preventScroll: true }); // ボタンが消えるので、フォーカスを知らせへ移す
    } });
    status.textContent = status.dataset.done;
    gsap.fromTo(status, { y: 10, opacity: 0 }, { y: 0, opacity: 1, duration: reduceMotion ? 0 : 0.35, delay: reduceMotion ? 0 : 0.2, ease: "power2.out" });
    coupon.dispatchEvent(new CustomEvent("coupon:receive", { bubbles: true })); // ここで受け取りの処理を呼ぶ
  };

  stub.addEventListener("pointerdown", (event) => {
    if (torn) return;
    stub.setPointerCapture(event.pointerId); // カードの外へはみ出しても追える
    tween?.kill();
    from = event.clientX - state.pull * THRESHOLD;
    start = event.clientX;
    moved = false;
  });
  stub.addEventListener("pointermove", (event) => {
    if (torn || !stub.hasPointerCapture(event.pointerId)) return;
    moved ||= Math.abs(event.clientX - start) > 3;
    state.pull = gsap.utils.clamp(0, 1, (event.clientX - from) / THRESHOLD);
    draw();
    if (state.pull < 1) return;
    stub.releasePointerCapture(event.pointerId); // しきい値に届いたら、離すのを待たずに切り離す
    tear();
  });
  // 途中で離したら、元の位置へ戻る
  const release = () => { if (!torn && moved) to({ pull: 0, duration: 0.4, ease: "power3.out" }); };
  stub.addEventListener("pointerup", release);
  stub.addEventListener("pointercancel", release);

  stub.addEventListener("click", (event) => {
    if (torn) return;
    // Enter・Space と支援技術の操作は detail が 0。引いて切り離す動きを流して受け取る
    if (event.detail === 0) to({ pull: 1, duration: 0.45, ease: "power2.in", onComplete: tear });
    // マウスや指で押しただけのときは、少し傾けて戻し、引く操作だと伝える
    else if (!moved && !reduceMotion) {
      tween?.kill();
      tween = gsap.timeline({ onUpdate: draw })
        .to(state, { pull: 0.25, duration: 0.15, ease: "power2.out" })
        .to(state, { pull: 0, duration: 0.4, ease: "power3.out" });
    }
  });
});

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

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