GODRICH

Coupon Design: 9 CSS Coupons, Stamp to Spin

Coupon design is what a discount looks like on screen and how it behaves — notch, tear line, stamp slot, countdown.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows one shopper through a discount instead of running down a list of techniques. It opens with the two that simply hand a discount over: the ticket you save to a wallet (01) and the card that fills one slot per visit (02). Then come the four that turn getting one into a game — spin (03), scratch (04), flip (05), and the wallet fan where three offers argue for themselves (06). The last three are the moments around the counter: the barcode you hold up at the register (07), the birthday card that has to be opened before it counts (08), and the coupon whose clock is nearly out (09). Stage colors rotate through ink, yellow, orange, and paper — cream is a card color here, never a stage — so no two neighbors in the grid glare at each other. Once a discount is chosen, the line item lands in 9 Cart and Checkout UI Patterns; the mail that carries the code before any of this happens is in 9 Marketing Email Templates, and the New / Event / Shipping pills the fanned cards wear come from 9 Status Tag and Badge Styles.

01Notched ticket coupon

Half-circle notches bite into the card at the top and bottom of the 72% boundary, cut by two radial-gradient masks combined with mask-composite, and the tear line beside them is an ordinary 2px dashed border. Pressing Save dips the button, then the wallet count cuts from 0 coupons to 1 coupon — the calmest run of the nine at 5.972% cumulative change, though all 23 sampled frames move. First-purchase download cards and promo landing pages.

maskradial-gradientcubic-bezier
.tk__card {
  display: flex; width: min(100%, 380px); background: $subject-cream; border-radius: $r-card;
  box-shadow: $shadow-raised; overflow: hidden;
  // two half-circle notches at the 72% boundary — mask-composite punches both at once
  -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;
}

02Stamp collection card

One order fills one slot: the stamp lands from scale(1.6) at -14deg, overshoots to .94 and settles at -8deg, and the same number is written into aria-valuenow so a screen reader hears the progress the eye sees. Ten slots fire 120ms apart, each off its own --k, and the run moves 19.376% of the stage across 19 of 23 frames before the Coupon issued chip pops. Cafe and membership loyalty cards, or the stamp board on an order-done screen.

transform-originscalearia-valuenow
.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); }
}
// each slot carries its own --k, so the ten stamps land 120ms apart
.sp.is-demo .sp__slot:nth-child(n) .sp__stamp--in { animation: spLoop 2s ease-out infinite; animation-delay: calc(var(--k, 0) * 120ms); animation-fill-mode: backwards; }
@keyframes spLoop {
  0% { visibility: hidden; transform: scale(1.6) rotate(-14deg); }
  1.5% { visibility: visible; }
  8% { transform: scale(.94) rotate(-6deg); }
  12%, 96% { visibility: visible; transform: scale(1) rotate(-8deg); }
  96.5%, 100% { visibility: hidden; transform: scale(1) rotate(-8deg); }
}

03Spin-to-win wheel

Eight prize slices come out of a single conic-gradient() with no SVG and no image file behind them. The preview turns exactly 1800deg — five full revolutions — so it ends on the angle it began at and never teleports; it is the second-busiest item here, 32.837% of the stage changing in total, with one frame alone at 25.194%. Signup welcome popups and first-launch draws.

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);
}
// 1800deg is exactly five revolutions — the loop lands where it started
.wh.is-demo .wh__disc { animation: whSpin 2s cubic-bezier(.12, .8, .08, 1) infinite; transition: none; }
@keyframes whSpin {
  0% { transform: rotate(0deg); }
  82%, 100% { transform: rotate(1800deg); }
}

04Scratch-off coupon

The foil is a real canvas, painted with a three-stop gradient and 26 random hairline cracks, and pointermove erases it for good through globalCompositeOperation set to destination-out. Every move samples the alpha channel to see how much has come off, and past 55% the remainder clears itself rather than making anyone finish the chore — 22.853% cumulative change over 19 of 23 frames. Random reward coupons and event result cards.

globalCompositeOperationdestination-outpointermove
// the real scratch — pointermove keeps erasing destination-out circles
var drawing = false;
function scratchAt(x, y) {
  ctx.globalCompositeOperation = 'destination-out';
  ctx.beginPath();
  ctx.arc(x, y, 16, 0, Math.PI * 2);
  ctx.fill();
}
// how much has come off — measured from the alpha samples
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;
}
zone.addEventListener('pointermove', function (e) {
  if (!drawing || scratched) { return; }
  var p = pos(e);
  scratchAt(p.x, p.y);
  if (clearedRatio() > .55) { finish(); }
});

05Flip gift card

Two faces sit stacked at inset: 0 with backface-visibility: hidden and the back pre-rotated 180deg, so one rotateY on the preserve-3d parent turns them as a single card instead of two sheets. That flip is the largest single-frame change of the nine — 33.669% of a 34.467% total — which is why the grid preview cuts between the faces rather than tweening through the edge-on view. Gifting pages and gift-value pickers.

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.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--back { background: $subject-cream; color: $subject-ink; transform: rotateY(180deg); gap: $sp-2; }
// the preview cuts front → back → front; the mid-rotation edge-on view kills the poster
.gc.is-demo .gc__card { animation: gcCut 2s steps(1, end) infinite; transition: none; }
@keyframes gcCut { 0%, 30% { transform: rotateY(0deg); } 30.5%, 85% { transform: rotateY(180deg); } 85.5%, 100% { transform: rotateY(0deg); } }

06Coupon stack fan

Three stacked cards read their own --i and spread by rotate plus translateX, so a hover, a keyboard focus, or one tap opens the fan and the three offers can be weighed side by side. The two back cards are deliberately lighter inks (#3a3346 and #5b5270) because near-identical dark cards still read as one card even when spread; the run changes 29.548% of the stage across 23 of 23 frames. Wallet screens and the picker just before checkout.

--irotatetranslateX
// hover, keyboard focus, or one tap on the stack opens the fan
.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--3,
.fan__stack:focus-within .fan__card--3,
.fan__stack.is-fanned .fan__card--3 { transform: rotate(8deg) translateX(min(40px, 11vw)) translateY(8px); }
// the preview holds the spread state and lifts one card out of it
.fan.is-demo .fan__card {
  transform: rotate(calc((var(--i) - 1) * 9deg)) translateX(calc((var(--i) - 1) * min(58px, 16vw))) translateY(calc(var(--i) * -4px));
}
@keyframes fanPick {
  0%, 16% { transform: translateY(-4px) scale(1); }
  40%, 70% { transform: translateY(-26px) scale(1.06); }
  88%, 100% { transform: translateY(-4px) scale(1); }
}

07Barcode scan coupon

The bars are two repeating-linear-gradient layers, one on a 4px period and one on 9px, offset 3px from each other — enough irregularity to read as a barcode with no image file and no font. The beam travels translateY(60px) in 800ms and the paid state cuts in behind it, the second-quietest run of the set behind the ticket at 12.737% over 15 of 23 frames. In-store payment screens and membership cards.

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); } }

08Birthday candle coupon

The flame is a 10×16px blob with an asymmetric border-radius and transform-origin: 50% 100%, so skewX and scaleY make it lean from its base the way a flame leans off its wick rather than slide sideways. A radial-gradient room layer warms the whole card while it burns and falls to zero the instant it is blown out, which is the moment the discount is locked in; the run measures 22.044% with a 22.035% peak frame. Birthday and anniversary coupons.

clip-pathtransform-originblur
.bd__flame {
  position: absolute; left: calc(50% - 5px); bottom: 42px;
  width: 10px; height: 16px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
  background: radial-gradient(circle at 50% 78%, #fff3c4, #ffb03a 62%, #ff6d3a 100%);
  transform-origin: 50% 100%;
}
// the sway stays alive in real use too — it is the state, not decoration
.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); }
}
// ignition is a one-frame cut so the lit room is the frame the poster picks
@keyframes bdRoom {
  0%, 13% { opacity: 0; animation-timing-function: steps(1, end); }
  14%, 40% { opacity: .95; animation-timing-function: ease-in-out; }
  52% { opacity: .7; }
  60% { opacity: .95; animation-timing-function: steps(1, end); }
  62%, 100% { opacity: 0; }
}

09Expiring coupon

A requestAnimationFrame loop turns the milliseconds that are left into a cell index and slides two digit reels by whole em steps, so the numerals change without a single layout pass. At zero the card takes grayscale(1), the code gets a line-through, and an Expired stamp appears below the digits — never over them, because a stamp across the number hides the thing the reader came to check; 22.603% across 15 of 23 frames. Promo deadlines and cart expiry alerts.

requestAnimationFramegrayscaleline-through
var DURATION = 12000;
function tick(ts) {
  if (ex.classList.contains('is-demo')) { requestAnimationFrame(tick); return; }  // the grid loop pauses real expiry
  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;                       // reel cell: 2, 1, 0, then 9 down to 0 — the (12 - sec)th cell from the top
  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 = 'The coupon has expired';
    return;
  }
  if (sec <= 5) { ex.classList.add('is-urgent'); }
  requestAnimationFrame(tick);
}

Where it breaks — the trap

The wheel broke three separate times, and no two of those breaks surfaced the same way. The labels came first: they started life as one inset: 0 plate rotated as a whole, and a rotated square's bounding box swells toward √2 times its side, so a 224px disc measured 310px tall through Playwright's getBoundingClientRect before the fix — taller than the 300px stage — and spilled off it. The same measurement reads 257px now. The cure was to stop rotating a plate at all — each label is now a 44×20px chip placed at rotate(k * 45deg + 22.5deg) translateY(var(--label-r)), and that 22.5deg is half a slice: drop it and every label parks on a slice border instead of in the middle of its color. The radius was the same bug in a different coat, a hard-coded translateY(-84px) throwing labels 18px clear of a 132px disc on narrow screens, which is why the radius moved into --label-r, dropping to -48px at 340px and under. The third break no measurement would ever have caught: the Draw button sat inside the disc, so the word turned upside down with the spin. A click found that one.

Two other items lost their poster to one shared mistake — putting a state change inside the preview loop. In the fanned stack the collapse-and-open round trip made the collapsing frames the largest per-frame change, so three renders in a row chose a stack of overlapping cards as the representative image, which is precisely what the item is not about; the loop now holds the spread state and only lifts one card, leaving collapse-to-fan to the real :hover and :focus-within transition. The birthday card had it worse: with the opening wipe inside the loop the poster was always the closed pink card and the candle never appeared at all, so the loop starts open, and since fading the flame in gradually made the biggest-change frame a still-dark room, ignition became a steps(1, end) cut.

Hover conceals one more failure, and it has nothing to do with posters. On a touch screen there is no hover, so the back cards of a stack can never be reached — a first tap now fans the stack through an is-fanned class before a second tap picks a card.

Two sweeps closed the rest. A Playwright pass over every on-screen string turned up 22 contrast failures, all of them measured before the fix and none reproducible from the shipped source: the wheel's status line sat on the orange stage at 3.11:1 in cream and still only 3.32:1 once it went white, so it moved onto an ink pill; the fanned cards' orange tags carried cream text at 3.11:1 and became yellow tags with ink text, now 12.63:1; and pale ink on cream from 2.78 to 4.04:1 across six items had its opacity raised from .5, .55 and .6 up to .68, .72 and .78. The before-fix readings are kept in _수리전실측.json beside the sources. At 320×200, before the fix, six items overflowed by 5 to 32px, and every one of those fixes lowered a value inside the narrow-screen media query rather than raising one — the stamp slot border from 2px to 1px, the disc from 204px to 132px, the card width from 268px to 228px. Support was checked on caniuse on 2026-09-12: conic-gradient() sits at 96.44% globally, from Chrome 69, Safari 12.1, Firefox 83, and Edge 79, with Opera Mini the one holdout, and CSS masks at 97.03% but only fully from Chrome 120, Safari 15.4, and Firefox 53 — which is exactly why the notched ticket ships -webkit-mask-composite: destination-in alongside the standard mask-composite: intersect. All nine sources, both the grid loop and the real interaction path, are in the file below, and it opens with rnpgy6qn.

Three variations

Variation Changed value How it feels
Six-slice wheel conic-gradient stops every 60deg, labels at --k * 60deg + 30deg Fatter prizes, still legible on a 320px screen
Wider scratch coin ctx.arc(x, y, 16, 0, Math.PI * 2) radius 16 to 28 A thumb clears the foil in two strokes instead of six
Four-column stamp card repeat(5, 1fr) becomes repeat(4, 1fr) The same ten slots reflow into four columns over three rows, so the card grows taller and narrower

AI prompt

The zip carries the full prompt; this is the shape of it. Name the nine coupons by their physical parts, not by CSS, and close with a negative list — image models will otherwise bevel everything and put a smiling hand on the card.

Design a coupon component board for an online shop: 9 cards, one board.
Flat vector, cream #fff7e6 cards on ink #17141a, blue #1c3f9e accents.
Show, in order: a ticket with half-circle notches at the top and bottom of its stub line,
a ten-slot stamp card with seven slots filled, an eight-slice prize wheel
with a pointer at 12 o'clock, a silver scratch panel half rubbed away,
a gift card caught mid-flip, three coupons fanned out of a stack,
a barcode card crossed by a green scan beam, an opened birthday card
with one lit candle, and a gray expired coupon under a red stamp.
Every card 4:3, shadows no softer than 2px, flat fills only.
Negative: gradients on text, 3D bevels, glitter, stock-photo hands.

Accessibility (reduced-motion)

Each item answers prefers-reduced-motion: reduce by keeping the finished state rather than the journey to it, so nothing disappears when the motion does. The ticket and the gift card drop every animation and transition, and the gift card simply rests on its back face. The stamp card shows all ten stamps at once, the wheel skips the spin and displays the winning message straight away, and the scratch panel parks at clip-path: inset(0 0 0 62%) with the coin gone — 62% of the foil cleared, nothing moving. The fan holds its spread transform with the animations off, the barcode leaves the beam at translateY(28px) with the paid state already applied, the birthday card stays lit with the room at .85, and the expiring coupon freezes on the healthy twelve-second state instead of the gray one. Focus rings are untouched throughout: every button carries a 3px :focus-visible outline, which is the one piece of feedback a keyboard user cannot do without.

FAQ

Why cut the notches with a mask instead of two overlaid circles?

Overlaid circles have to match whatever is behind the card, so the trick collapses the moment the coupon sits on a photo or a gradient. A mask removes the pixels, which means the background actually shows through, and mask-composite: intersect lets both notches come off one element instead of two stacked wrappers.

Can the spin wheel land on a prize I choose?

Yes, and it should. The loop in the grid is decorative and always turns 1800deg, but the click path applies a transition to a target angle, so the server decides the winning slice and the CSS only animates toward the angle that slice occupies.

Does the countdown keep running when the tab is in the background?

requestAnimationFrame is throttled or paused in a hidden tab, so the reel stops updating. Because the remaining time is derived from a timestamp difference on every frame and not from a counter, the digits are correct again on the first frame after the tab comes back.

Enter the archive password

The password is inside this article. You will find it as you read.