デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- 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" でスクリーンリーダーに伝えています。
コード
<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>
.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; }
}
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" });
}
});
});