GODRICH

クーポン デザイン 9選 — スタンプからルーレットまで

クーポン デザインとは、割引を配るための見た目(チケット、スタンプカード、ルーレット、スクラッチ)をHTMLとCSSで組み立てる仕事です。9つとも480×300の舞台で実際に動かして測り、320px幅でもはみ出さないところまで直してあります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つは技術の難しさ順ではなく、買い物客がクーポンに出会う順に並べています。まず手元に受け取る3つ — ミシン目の付いたチケットを保存する01、通うたびに枠が埋まるスタンプカード02、盤を回して割引を引くルーレット03。次は「開けてみる」楽しみの3つ — コインで削るスクラッチ04、裏に残高とPINを隠したギフトカード05、3枚を扇状に広げて見比べるウォレット06。最後は使い切るまでの3つ — レジで読ませるバーコード07、ろうそくを消すと割引が確定する誕生日カード08、0秒になるとグレーに沈む期限間近クーポン09です。舞台色は墨・イエロー・オレンジ・紙で回し、隣り合う2つが同じ明るさにならないようにしました。カートに載せたあとの割引行や合計の見せ方は決済画面 デザイン 9選、配ったクーポンをメールの中でどう組むかはメルマガ デザイン 9種、「新規」「送料無料」のような小さなラベルはステータス バッジ UI 9選が受け持ちます。

01ノッチ付きチケットクーポン

ミシン目の上下にある半円のえぐれは画像ではなく、radial-gradientを2枚重ねたmaskで前面を本当にくり抜いています。プレビューではカードが2秒かけて4px上下し、45%で「保存」が「保存済み」へ、54%でウォレットの数字が0から1へ切り替わります(面積変化5.972%、23フレーム中23フレームが動きます)。初回購入クーポンのダウンロードカードや、LPのクーポンカードに使います。

maskradial-gradientcubic-bezier
.tk__card {
  display: flex; width: min(100%, 380px); background: $subject-cream; border-radius: $r-card;
  box-shadow: $shadow-raised; overflow: hidden;
  // 半円のノッチ2つ — 右の境界72%の位置の上と下。mask-composite で2つの穴を同時に
  -webkit-mask:
    radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
    radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
  -webkit-mask-composite: destination-in;
  mask:
    radial-gradient(circle 10px at 72% 0, transparent 96%, #000 100%),
    radial-gradient(circle 10px at 72% 100%, transparent 96%, #000 100%);
  mask-composite: intersect;
}

02スタンプカード

スタンプは1.6倍の大きさ・-14degの傾きから、1倍・-8degへ叩き込まれます。プレビューでは10枠が120ms差で順に埋まり、72%で「スタンプを押す」ボタンが消えて発行チップが跳ね出ます(面積変化19.376%、23フレーム中19フレームが動きます)。カフェや会員のスタンプカード、注文完了画面のスタンプボードに使います。

transform-originscalearia-valuenow
.sp__stamp { display: flex; width: 88%; height: 88%; color: $subject-blue; }
.sp__stamp svg { width: 100%; height: 100%; }
.sp__stamp--in { transform: rotate(-8deg); }
.sp__slot:nth-child(3n) .sp__stamp--in { transform: rotate(5deg); }
.sp__slot:nth-child(4n) .sp__stamp--in { transform: rotate(-3deg); }
.sp__stamp--new { animation: spStampIn 300ms $easing both; transform-origin: 50% 50%; }
@keyframes spStampIn {
  0% { transform: scale(1.6) rotate(-14deg); }
  70% { transform: scale(.94) rotate(-6deg); }
  100% { transform: scale(1) rotate(-8deg); }
}

03ルーレットクーポン

8分割の盤は画像を使わず、conic-gradient一行で塗っています。「引く」ボタンを押すと1800 + (360 - (idx * 45 + 22.5))度まで1.8秒の減速イージングで回り、当たり枠の中心がポインターの真下で止まります(面積変化32.837%、23フレーム中21フレームが動きます)。会員登録のウェルカムポップアップや、初回起動のルーレットに使います。

conic-gradientcubic-bezierrotate
.wh__disc {
  position: relative; width: 100%; height: 100%; border-radius: 50%;
  background:
    conic-gradient(
      $subject-cream 0deg 45deg,
      #1c3f9e 45deg 90deg,
      $subject-cream 90deg 135deg,
      #1c3f9e 135deg 180deg,
      $subject-cream 180deg 225deg,
      #1c3f9e 225deg 270deg,
      $subject-cream 270deg 315deg,
      #1c3f9e 315deg 360deg
    );
  box-shadow: $shadow-raised;
  transition: transform 1.8s cubic-bezier(.12, .8, .08, 1);
}

04スクラッチクーポン

銀箔はcanvasに塗り、pointermoveのたびに半径16pxの円をdestination-outで本当に削ります。剥がれた量はアルファ値が40未満の画素を数えて実測し、55%を超えた瞬間に残りを一気に消して割引を出します(面積変化22.853%、23フレーム中19フレームが動きます)。ランダム報酬クーポンや、キャンペーン応募の結果カードに使います。

globalCompositeOperationdestination-outpointermove
// 実際のこすり — pointermove で destination-out の円を消し続ける
function scratchAt(x, y) {
  ctx.globalCompositeOperation = 'destination-out';
  ctx.beginPath();
  ctx.arc(x, y, 16, 0, Math.PI * 2);
  ctx.fill();
}
// どれだけ剥がれたか — アルファ値のサンプルで実測
function clearedRatio() {
  var data = ctx.getImageData(0, 0, foil.width, foil.height).data;
  var clear = 0, total = data.length / 4;
  for (var i = 3; i < data.length; i += 4) { if (data[i] < 40) { clear++; } }
  return clear / total;
}

05ギフトカードの反転

表と裏は同じ場所に重ねた2枚で、裏面にだけ最初からrotateY(180deg)を当てておき、親を180度回すと2枚が一枚のように噛み合います。プレビューは回転の途中(紙の小口だけが見える瞬間)を描かないsteps(1, end)のカットで、30%で裏返り85%で戻ります(面積変化34.467%、23フレーム中23フレームが動きます)。ギフトページや、金額チケットの選択カードに使います。

preserve-3drotateYbackface-visibility
.gc__card {
  position: relative; width: min(100%, 288px); aspect-ratio: 288 / 168;
  transform-style: preserve-3d; cursor: pointer;
  transition: transform 600ms $easing;
}
.gc__card:focus-visible { outline: 3px solid $stage-yellow; outline-offset: 4px; border-radius: $r-card; }
.gc.is-flipped .gc__card { transform: rotateY(180deg); }
.gc__face {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: $sp-1;
  border-radius: $r-card; box-shadow: $shadow-raised;
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
  padding: $sp-4;
}
.gc__face--front { background: $subject-lilac; color: $subject-ink; }
.gc__face--back { background: $subject-cream; color: $subject-ink; transform: rotateY(180deg); gap: $sp-2; }

06クーポン束の扇ひろげ

3枚のカードは同じ場所にinset: 0で重なっていて、束にホバーかフォーカスが入るとrotateとtranslateXで左右へ開きます。後ろの2枚は地の色を明るくずらし(#3a3346・#5b5270)、一番奥の1枚だけ内容を右端に寄せて、開いたときに前のカードに丸ごと隠れないようにしました(面積変化29.548%、23フレーム中23フレームが動きます)。マイページのクーポンウォレットや、決済前のクーポン選択に使います。

--irotatetranslateX
// 実際の操作: 束にホバー・フォーカスが触れるか(マウス・キーボード)、一度タップすると(タッチ)扇に開く
.fan__stack:hover .fan__card--1,
.fan__stack:focus-within .fan__card--1,
.fan__stack.is-fanned .fan__card--1 { transform: rotate(-8deg) translateX(min(-34px, -9vw)) translateY(-4px); }
.fan__stack:hover .fan__card--2,
.fan__stack:focus-within .fan__card--2,
.fan__stack.is-fanned .fan__card--2 { transform: translateY(-12px); }
.fan__stack:hover .fan__card--3,
.fan__stack:focus-within .fan__card--3,
.fan__stack.is-fanned .fan__card--3 { transform: rotate(8deg) translateX(min(40px, 11vw)) translateY(8px); }
.fan__card[aria-pressed="true"] { transform: translateY(-8px); box-shadow: $shadow-raised; z-index: 4; }

07バーコードスキャンクーポン

バーコードは画像でもSVGでもなく、周期4pxと9pxのrepeating-linear-gradientを2枚重ねて不規則な太さの縞にしています。「スキャン」を押すと高さ4pxの光の帯が800msで60px下り、プレビューでは73%のカットで縞が薄くなって決済完了へ変わります(面積変化12.737%、23フレーム中15フレームが動きます)。店頭の決済画面や、会員バーコードカードに使います。

repeating-linear-gradienttranslateYaria-live
.bc__bars {
  position: relative; margin-top: $sp-2; height: 64px; border-radius: $r-sm; overflow: hidden;
  background:
    repeating-linear-gradient(90deg, $subject-ink 0 2px, transparent 2px 4px),
    repeating-linear-gradient(90deg, $subject-ink 0 3px, transparent 3px 9px);
  background-position: 0 0, 3px 0;
}
.bc__beam {
  position: absolute; left: 0; right: 0; top: 0; height: 4px; visibility: hidden;
  background: linear-gradient(180deg, transparent, rgba(76,212,166,.95) 40%, rgba(23,20,26,.9) 55%, transparent);
  box-shadow: 0 0 8px rgba(76,212,166,.8);
}
.bc.is-scanning .bc__beam { visibility: visible; animation: bcBeamOnce 800ms ease-in-out; }
@keyframes bcBeamOnce { 0% { transform: translateY(0); } 100% { transform: translateY(60px); } }

08誕生日クーポン

炎はtransform-origin: 50% 100%の下端を軸にskewXとscaleYで揺れ、その足元にblur(4px)のにじみを重ねています。火が灯っている間だけカードの地に暖色のradial-gradientがかかり、「火を消す」ボタンを押すとis-lit-offが炎をscaleY(0)に畳んで割引が確定します(面積変化22.044%、23フレーム中17フレームが動きます)。誕生日のメッセージカードや、記念日クーポンに使います。

clip-pathtransform-originblur
// 炎のゆらぎ — 実使用でも生き続けます(飾りではなく状態の表示です)
.bd__flame { animation: bdFlicker 900ms ease-in-out infinite; }
@keyframes bdFlicker {
  0%, 100% { transform: skewX(0deg) scaleY(1) scaleX(1); }
  25% { transform: skewX(4deg) scaleY(1.08) scaleX(.94); }
  55% { transform: skewX(-3deg) scaleY(.94) scaleX(1.05); }
  80% { transform: skewX(2deg) scaleY(1.05) scaleX(.97); }
}
.bd__glow { animation: bdGlow 900ms ease-in-out infinite; }
@keyframes bdGlow { 0%, 100% { transform: scale(1); opacity: .9; } 50% { transform: scale(1.25); opacity: .55; } }

09期限間近クーポン

残り時間はrequestAnimationFrameで毎フレーム計算し、数字のリールをtranslateY(-Nem)12 - sec番目のセルまでずらして見せます。0になるとis-expiredが付き、カード全体にgrayscale(1)、コード文字にline-throughがかかって使い終わったクーポンに変わります(面積変化22.603%、23フレーム中15フレームが動きます)。キャンペーンの締め切り案内や、カートクーポンの期限切れ通知に使います。

requestAnimationFramegrayscaleline-through
function tick(ts) {
  if (ex.classList.contains('is-demo')) { requestAnimationFrame(tick); return; }  // 格子ループ中はリアルタイムの期限切れを止める
  if (start === null) { start = ts; }
  var left = Math.max(0, DURATION - (ts - start));
  var sec = Math.min(12, Math.ceil(left / 1000));
  var oneK = 12 - sec;                       // リールのセル: 2, 1, 0, そのあと 9 から 0 まで — 上から 12-sec 番目
  var tenK = sec >= 10 ? 0 : 1;
  document.querySelector('.ex__reel--ten').style.transform = 'translateY(' + (-tenK) + 'em)';
  document.querySelector('.ex__reel--one').style.transform = 'translateY(' + (-oneK) + 'em)';
  if (left <= 0) {
    ex.classList.add('is-expired');
    warn.textContent = 'クーポンの期限が切れました';
    return;
  }
  if (sec <= 5) { ex.classList.add('is-urgent'); }
  requestAnimationFrame(tick);
}

どこで壊れるか — 落とし穴

一番手こずったのは03のルーレットです。最初はラベルをinset: 0の板にまとめて円盤ごと回していたのですが、回転後のバウンディングボックスの高さが円盤の直径の√2倍(224px → 310px、修理前にPlaywrightのgetBoundingClientRectで実測)に膨らみ、舞台の高さ300pxを超えてはみ出しました。そこでラベルを44×20pxの小片に分け、rotate(k * 45deg + 22.5deg) translateY(var(--label-r))で枠の中央へ置き直したところ、同じ測り方で257pxに収まっています。22.5degを抜くとラベルが枠の境界にまたがって、どの枠の文字なのか読めなくなります。ラベルの半径もtranslateY(-84px)の固定pxのままでは、狭い画面(円盤132px、半径66px)でラベルが円盤の外へ18px飛び出したのでvar(--label-r)に切り出し、340px以下では-48pxにしました。もう一つ、「引く」ボタンを円盤の中に置いていたときは、盤と一緒に文字まで逆さまに回りました。クリックして初めて分かる種類の壊れ方で、円盤の外の兄弟要素へ移して直しています。

プレビューのループでは、06と08が同じ罠にはまりました。06で「たたむ→広げる」の往復をループに入れると、たたむ区間のフレームごとの変化が最も大きく、再レンダリング3回とも代表画像(poster)が「重なった束」になりました。主役の扇が写らないので、ループは広げた状態を保ち、1枚を持ち上げる演出だけ残しています。たたむ→広げる自体は実使用の:hover:focus-withinのトランジションに任せました。08も同じで、開封のワイプをループに入れるとposterがいつも閉じたピンクのカードになり、ろうそくが写りません。ループは開いた状態から始め、さらに点火をゆっくり明るくすると最大変化のフレームが「まだ暗い途中」になるため、steps(1, end)の一コマのカットにしています。06にはもう一つ、ホバーのない端末では重なった後ろのカードをいつまでも押せないという問題があり、最初のタップで束が広がるis-fannedの経路を足しました。

色と幅は数字で詰めました。Playwrightで画面上の文字のコントラストを全数測定したところ、修理前の時点で22件が足りていませんでした — ここから挙げる値はどれも直す前のもので、出荷したソースからは再現しません。03の案内文はオレンジの舞台の上にクリーム文字で3.11:1、白に上げても3.32:1で届かず、最後は墨色のピルの上へ移しました。06の扇のタグはオレンジ地(#ff4d1f)にクリーム文字で3.11:1だったので、黄色のタグに墨色の文字へ替えていまは12.63:1です。クリーム地の薄い墨色2.78〜4.04:1(01・02・04・05・08・09)は不透明度を.5〜.6から.68〜.78へ上げました。修理前の実測はソースの隣の_수리전실측.jsonに残してあります。320×200でも修理前は6項目(02・03・05・06・07・08)が5〜32pxはみ出していましたが、狭い画面のメディアクエリでは余白を広げずに狭めて解決しました — スタンプ枠の線を2pxから1pxへ、円盤を204pxから132pxへ、カード幅を268pxから228pxへ。ブラウザ対応もcaniuseで確認済みで(2026-09-12時点)、conic-gradient()は全体96.44%(Chrome 69+・Safari 12.1+・Firefox 83+・Edge 79+、Opera Miniは非対応)、CSS masksは全体97.03%で完全対応はChrome 120 / Safari 15.4 / Firefox 53以降です。それ以前は部分対応なので、01のノッチは-webkit-mask-webkit-mask-composite: destination-inと標準のmask-composite: intersectを併記してあります。仕様の読み方はMDN の conic-gradientが手早いです。ここまでの直しをすべて入れた9つのソースは圧縮ファイルにまとめてあり、開くためのパスワードは rnpgy6qn です。

変形3つ

値をひとつ差し替えるだけで別のキャンペーンになります。

名前 変えた値 一行の印象
ノッチの位置 01 .tk__cardのmaskのat 72%at 50% えぐれがカード中央へ移り、二つ折りの招待券の顔になります(ミシン目もそろえるなら.tk__stubflex: 0 0 28%0 0 50%へ)
枠の並び 02 .sp__gridrepeat(5, 1fr)repeat(4, 1fr) 10枠が4列3段に組み替わり、縦に伸びたカードになります
開き角 06 .fan.is-demo .fan__card9deg14deg 扇が大きく開き、3枚より多い束にも耐えます

AIプロンプト

コードごと作らせるより、配色と役割だけ先に決めさせるほうが速く収まります。下の指示はそのまま貼って使えます。

ECサイトのクーポンカードを1枚、HTMLとSCSSで作れ。
条件:
- 幅は最大380px、高さは可変。画像とアイコンフォントは使うな
- ミシン目の上下にある半円の切り欠きは mask と radial-gradient で作れ。画像で代用するな
- 保存ボタンは1つだけ。押したら aria-pressed を切り替え、枚数を aria-live で知らせろ
- 色はクリーム地(#fff7e6)に青(#1c3f9e)のアクセント1色まで
- すべてのアニメーションを prefers-reduced-motion: reduce で止め、最終状態を残せ
- 320px幅で横にはみ出さないこと。確認した幅を最後に書け
出力はHTMLとSCSSのみ。説明文は付けるな。

アクセシビリティ

prefers-reduced-motion: reduceでは9つとも動きを止めますが、消すのではなく「終わった状態」を残します。01はカードの浮きとボタンの押し込みを止め、02は10枠を押された姿のまま見せ、03は回転せずに当たりの文言だけを出します。04はコインと銀箔の残り片をdisplay: noneにし、銀箔が62%まで削れた静止状態で割引率を見せ、05は裏返した面で固定、07は帯を途中で止めて決済完了の姿に、08は炎を灯ったまま静止させ、09は残り12秒の生きたカードで止めます。動き以外の手当ても入れてあります。02の進み具合はaria-valuenowに写し、01の枚数・04の結果・07の決済完了・09の期限切れはいずれもaria-liveの領域が読み上げます。ボタンはすべて:focus-visibleに3pxのアウトラインを持ち、06はホバーだけでなく:focus-withinでも扇が開くので、キーボードだけでも3枚を見比べられます。

FAQ

ルーレットの当たりをサーバーで決めたいときは?

引く角度を決めているtargetの計算だけを差し替えます。当選枠の番号をAPIから受け取り、idxにそのまま入れれば、回転の見た目には一切手を入れずに結果だけをサーバー側へ移せます。演出は1.8秒なので、応答を待ってから回し始めても違和感はありません。

スクラッチはスマホの指でも削れますか?

pointerdownpointermoveを使っているので、マウス・指・ペンのどれでも同じ経路で削れます。canvasにはtouch-action: noneを当ててあり、削っている最中にページが一緒にスクロールすることはありません。削れない環境のために「すぐ見る」ボタンも並べてあります。

スタンプの数を10個から変えても崩れませんか?

.sp__gridrepeat(5, 1fr)を変えれば列数が、HTMLの枠を足し引きすれば総数が変わります。枠はaspect-ratio: 1の円なので列数に合わせて自動で縮み、320px幅では枠線を1pxに落とすメディアクエリが効きます。ループの遅延は--kに入れた通し番号×120msなので、枠を足したらその番号も続けて振ってください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。