Coupon
import { useId, useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
@keyframes field-coupon-travel { from { offset-distance: 0%; } to { offset-distance: 100%; } }
@keyframes field-coupon-cut { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes field-coupon-snip { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(var(--a)); } }
@keyframes field-coupon-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-6px); } 40% { transform: translateX(5px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } }
.field-coupon { width: 300px; margin: 0; }
.field-coupon__card { position: relative; width: 300px; height: 108px; }
.field-coupon__card[data-state='error'] { animation: field-coupon-shake 0.4s ease-out; }
/* What the cut leaves on the page: an empty, shadowed hole. */
.field-coupon__hole { position: absolute; inset: 4px; border-radius: 12px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 2px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); opacity: 0; transition: opacity 0.3s; }
.field-coupon__piece {
position: absolute;
inset: 4px;
box-sizing: border-box;
padding: 14px 18px;
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
transition: transform 0.45s cubic-bezier(0.3, 1.4, 0.5, 1), box-shadow 0.45s;
}
.field-coupon__edge { position: absolute; inset: 0; width: 300px; height: 108px; overflow: visible; pointer-events: none; }
.field-coupon__dash { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; stroke-dasharray: 6 5; transition: opacity 0.3s; }
.field-coupon__cut { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-dasharray: 1; stroke-dashoffset: 1; transition: opacity 0.3s; }
.field-coupon__scissors { position: absolute; left: 0; top: 0; width: 22px; height: 22px; offset-path: path('M16 4H284A12 12 0 0 1 296 16V92A12 12 0 0 1 284 104H16A12 12 0 0 1 4 92V16A12 12 0 0 1 16 4'); offset-rotate: auto; offset-anchor: 50% 50%; transition: opacity 0.25s; }
.field-coupon__scissors svg { display: block; width: 22px; height: 22px; overflow: visible; }
.field-coupon__blade { fill: light-dark(#50535b, #c6c8cd); }
.field-coupon__loop { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.8; }
.field-coupon__half { transform-box: view-box; transform-origin: 11px 11px; }
.field-coupon__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-coupon__row { display: flex; gap: 8px; }
.field-coupon__input {
flex: 1;
min-width: 0;
height: 40px;
box-sizing: border-box;
padding: 0 12px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 600 16px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
text-transform: uppercase;
outline: none;
}
.field-coupon__input::placeholder { color: light-dark(#6d7179, #7d8087); letter-spacing: 0; text-transform: none; font-family: system-ui, sans-serif; font-weight: 400; }
.field-coupon__input:focus-visible { border-color: light-dark(#50535b, #c6c8cd); box-shadow: 0 0 0 3px color-mix(in srgb, light-dark(#5f626a, #a7abb2) 30%, transparent); }
.field-coupon__apply { height: 40px; padding: 0 16px; border: 0; border-radius: 8px; background: light-dark(#42454c, #e5e6e9); color: light-dark(#f3f4f7, #1a1c1f); font: 600 14px system-ui, sans-serif; cursor: pointer; }
.field-coupon__apply:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.field-coupon__status { min-height: 18px; margin: 10px 0 0; color: light-dark(#5f626a, #a7abb2); font-size: 13px; }
/* Cutting: the scissors run the dashed line once, leaving a clean cut behind them. */
.field-coupon__card[data-state='cut'] .field-coupon__scissors { animation: field-coupon-travel 1.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; }
.field-coupon__card[data-state='cut'] .field-coupon__cut { animation: field-coupon-cut 1.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; }
.field-coupon__card[data-state='cut'] .field-coupon__half { animation: field-coupon-snip 0.16s linear infinite; }
/* Cut out: the piece lifts off and tilts above its hole. */
.field-coupon__card[data-state='done'] .field-coupon__hole { opacity: 1; }
.field-coupon__card[data-state='done'] .field-coupon__piece { transform: translate(-3px, -7px) rotate(-2deg); box-shadow: 0 10px 20px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)), 0 0 0 1px light-dark(#cbced3, #393c41); }
.field-coupon__card[data-state='done'] .field-coupon__dash, .field-coupon__card[data-state='done'] .field-coupon__cut, .field-coupon__card[data-state='done'] .field-coupon__scissors { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.field-coupon__piece, .field-coupon__hole { transition: none; }
.field-coupon__card[data-state] .field-coupon__scissors, .field-coupon__card[data-state] .field-coupon__cut, .field-coupon__card[data-state] .field-coupon__half, .field-coupon__card[data-state='error'] { animation: none; }
}
`;
const CODE = 'SAVE20';
const EDGE = 'M16 4H284A12 12 0 0 1 296 16V92A12 12 0 0 1 284 104H16A12 12 0 0 1 4 92V16A12 12 0 0 1 16 4';
export function CouponInput() {
const id = useId();
const [v, setV] = useState('');
const [state, setState] = useState<'idle' | 'cut' | 'done' | 'error'>('idle');
const msg = { idle: `Try ${CODE}`, cut: 'Checking…', done: '20% off applied', error: `That code didn't work. Try ${CODE}.` }[state];
return (
<>
<style>
{css}
</style>
<form
className="field-coupon"
onSubmit={(e) => {
e.preventDefault();
if (state === 'cut' || state === 'done') return;
if (v.trim().toUpperCase() !== CODE) return setState('error');
setState(matchMedia('(prefers-reduced-motion: reduce)').matches ? 'done' : 'cut');
}}
>
<div className="field-coupon__card" data-state={state} onAnimationEnd={(e) => e.animationName === 'field-coupon-shake' && setState('idle')}>
<span className="field-coupon__hole" />
<div className="field-coupon__piece">
<label className="field-coupon__label" htmlFor={id}>
Promo code
</label>
<div className="field-coupon__row">
<input
id={id}
className="field-coupon__input"
value={v}
placeholder="Enter code"
autoComplete="off"
spellCheck={false}
aria-describedby={`${id}-status`}
onChange={(e) => {
setV(e.target.value);
if (state !== 'idle') setState('idle');
}}
/>
<button className="field-coupon__apply" type="submit">
Apply
</button>
</div>
</div>
<svg className="field-coupon__edge" viewBox="0 0 300 108" aria-hidden="true">
<rect className="field-coupon__dash" x="4" y="4" width="292" height="100" rx="12" />
<path className="field-coupon__cut" pathLength={1} d={EDGE} />
</svg>
<span className="field-coupon__scissors" aria-hidden="true" onAnimationEnd={(e) => e.animationName === 'field-coupon-travel' && setState('done')}>
<svg viewBox="0 0 22 22">
<g className="field-coupon__half" style={{ '--a': '-14deg' } as CSSProperties}>
<path className="field-coupon__blade" d="M11 11L21 8.5L21.5 10Z" />
<circle className="field-coupon__loop" cx="4.5" cy="14.5" r="3" />
<path className="field-coupon__loop" d="M7 13L11 11" />
</g>
<g className="field-coupon__half" style={{ '--a': '14deg' } as CSSProperties}>
<path className="field-coupon__blade" d="M11 11L21 13.5L21.5 12Z" />
<circle className="field-coupon__loop" cx="4.5" cy="7.5" r="3" />
<path className="field-coupon__loop" d="M7 9L11 11" />
</g>
</svg>
</span>
</div>
<p className="field-coupon__status" id={`${id}-status`} role="status">
{msg}
</p>
</form>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldCoupon(el) on the mounted element instead.
<form class="field-coupon">
<div class="field-coupon__card" data-state="idle">
<span class="field-coupon__hole"></span>
<div class="field-coupon__piece">
<label class="field-coupon__label" for="field-coupon">Promo code</label>
<div class="field-coupon__row">
<input id="field-coupon" class="field-coupon__input" placeholder="Enter code" autoComplete="off" spellCheck="false" aria-describedby="field-coupon-status" value=""/>
<button class="field-coupon__apply" type="submit">Apply</button>
</div>
</div>
<svg class="field-coupon__edge" viewBox="0 0 300 108" aria-hidden="true">
<rect class="field-coupon__dash" x="4" y="4" width="292" height="100" rx="12"></rect>
<path class="field-coupon__cut" pathLength="1" d="M16 4H284A12 12 0 0 1 296 16V92A12 12 0 0 1 284 104H16A12 12 0 0 1 4 92V16A12 12 0 0 1 16 4"></path>
</svg>
<span class="field-coupon__scissors" aria-hidden="true">
<svg viewBox="0 0 22 22">
<g class="field-coupon__half" style="--a:-14deg">
<path class="field-coupon__blade" d="M11 11L21 8.5L21.5 10Z"></path>
<circle class="field-coupon__loop" cx="4.5" cy="14.5" r="3"></circle>
<path class="field-coupon__loop" d="M7 13L11 11"></path>
</g>
<g class="field-coupon__half" style="--a:14deg">
<path class="field-coupon__blade" d="M11 11L21 13.5L21.5 12Z"></path>
<circle class="field-coupon__loop" cx="4.5" cy="7.5" r="3"></circle>
<path class="field-coupon__loop" d="M7 9L11 11"></path>
</g>
</svg>
</span>
</div>
<p class="field-coupon__status" id="field-coupon-status" role="status">Try SAVE20</p>
</form>@keyframes field-coupon-travel { from { offset-distance: 0%; } to { offset-distance: 100%; } }
@keyframes field-coupon-cut { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes field-coupon-snip { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(var(--a)); } }
@keyframes field-coupon-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-6px); } 40% { transform: translateX(5px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } }
.field-coupon { width: 300px; margin: 0; }
.field-coupon__card { position: relative; width: 300px; height: 108px; }
.field-coupon__card[data-state='error'] { animation: field-coupon-shake 0.4s ease-out; }
/* What the cut leaves on the page: an empty, shadowed hole. */
.field-coupon__hole { position: absolute; inset: 4px; border-radius: 12px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 2px 6px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); opacity: 0; transition: opacity 0.3s; }
.field-coupon__piece {
position: absolute;
inset: 4px;
box-sizing: border-box;
padding: 14px 18px;
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
transition: transform 0.45s cubic-bezier(0.3, 1.4, 0.5, 1), box-shadow 0.45s;
}
.field-coupon__edge { position: absolute; inset: 0; width: 300px; height: 108px; overflow: visible; pointer-events: none; }
.field-coupon__dash { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; stroke-dasharray: 6 5; transition: opacity 0.3s; }
.field-coupon__cut { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-dasharray: 1; stroke-dashoffset: 1; transition: opacity 0.3s; }
.field-coupon__scissors { position: absolute; left: 0; top: 0; width: 22px; height: 22px; offset-path: path('M16 4H284A12 12 0 0 1 296 16V92A12 12 0 0 1 284 104H16A12 12 0 0 1 4 92V16A12 12 0 0 1 16 4'); offset-rotate: auto; offset-anchor: 50% 50%; transition: opacity 0.25s; }
.field-coupon__scissors svg { display: block; width: 22px; height: 22px; overflow: visible; }
.field-coupon__blade { fill: light-dark(#50535b, #c6c8cd); }
.field-coupon__loop { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.8; }
.field-coupon__half { transform-box: view-box; transform-origin: 11px 11px; }
.field-coupon__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-coupon__row { display: flex; gap: 8px; }
.field-coupon__input {
flex: 1;
min-width: 0;
height: 40px;
box-sizing: border-box;
padding: 0 12px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 600 16px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
text-transform: uppercase;
outline: none;
}
.field-coupon__input::placeholder { color: light-dark(#6d7179, #7d8087); letter-spacing: 0; text-transform: none; font-family: system-ui, sans-serif; font-weight: 400; }
.field-coupon__input:focus-visible { border-color: light-dark(#50535b, #c6c8cd); box-shadow: 0 0 0 3px color-mix(in srgb, light-dark(#5f626a, #a7abb2) 30%, transparent); }
.field-coupon__apply { height: 40px; padding: 0 16px; border: 0; border-radius: 8px; background: light-dark(#42454c, #e5e6e9); color: light-dark(#f3f4f7, #1a1c1f); font: 600 14px system-ui, sans-serif; cursor: pointer; }
.field-coupon__apply:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.field-coupon__status { min-height: 18px; margin: 10px 0 0; color: light-dark(#5f626a, #a7abb2); font-size: 13px; }
/* Cutting: the scissors run the dashed line once, leaving a clean cut behind them. */
.field-coupon__card[data-state='cut'] .field-coupon__scissors { animation: field-coupon-travel 1.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; }
.field-coupon__card[data-state='cut'] .field-coupon__cut { animation: field-coupon-cut 1.3s cubic-bezier(0.4, 0, 0.6, 1) forwards; }
.field-coupon__card[data-state='cut'] .field-coupon__half { animation: field-coupon-snip 0.16s linear infinite; }
/* Cut out: the piece lifts off and tilts above its hole. */
.field-coupon__card[data-state='done'] .field-coupon__hole { opacity: 1; }
.field-coupon__card[data-state='done'] .field-coupon__piece { transform: translate(-3px, -7px) rotate(-2deg); box-shadow: 0 10px 20px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)), 0 0 0 1px light-dark(#cbced3, #393c41); }
.field-coupon__card[data-state='done'] .field-coupon__dash, .field-coupon__card[data-state='done'] .field-coupon__cut, .field-coupon__card[data-state='done'] .field-coupon__scissors { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.field-coupon__piece, .field-coupon__hole { transition: none; }
.field-coupon__card[data-state] .field-coupon__scissors, .field-coupon__card[data-state] .field-coupon__cut, .field-coupon__card[data-state] .field-coupon__half, .field-coupon__card[data-state='error'] { animation: none; }
}function initFieldCoupon(root) {
var CODE = 'SAVE20';
var MSG = { idle: 'Try ' + CODE, cut: 'Checking…', done: '20% off applied', error: "That code didn't work. Try " + CODE + '.' };
var card = root.querySelector('.field-coupon__card');
var input = root.querySelector('.field-coupon__input');
var status = root.querySelector('.field-coupon__status');
var state = 'idle';
function set(next) {
state = next;
card.setAttribute('data-state', next);
status.textContent = MSG[next];
}
// A good code sends the scissors round the edge; a wrong one shakes the coupon.
function onSubmit(e) {
e.preventDefault();
if (state === 'cut' || state === 'done') return;
if (input.value.trim().toUpperCase() !== CODE) return set('error');
set(matchMedia('(prefers-reduced-motion: reduce)').matches ? 'done' : 'cut');
}
function onInput() {
if (state !== 'idle') set('idle');
}
// Cut all the way round: the piece lifts out. Done shaking: back to idle.
function onEnd(e) {
if (e.animationName === 'field-coupon-travel') set('done');
if (e.animationName === 'field-coupon-shake') set('idle');
}
root.addEventListener('submit', onSubmit);
input.addEventListener('input', onInput);
card.addEventListener('animationend', onEnd);
return function () {
root.removeEventListener('submit', onSubmit);
input.removeEventListener('input', onInput);
card.removeEventListener('animationend', onEnd);
};
}
document.querySelectorAll('.field-coupon').forEach(initFieldCoupon);Goal: Create a "Coupon" input.
Visual behavior: A promo code on a coupon: a valid code sends scissors round the dashed edge, cutting it out so it lifts off the page; a wrong one makes it shake.
Technique:
- The dashed edge is an SVG rect. The cut is a solid path on the same outline drawn in by its dash offset while scissors ride that path on offset-path, their blades snipping.
- A small state machine (idle, cut, done, error) sits on a data attribute. When the scissors finish, the piece lifts and tilts above an empty hole; a wrong code plays a shake and returns to idle when it ends.
Accessibility: A form with a labelled input and a submit button; the result is read out from a role="status" line that also holds the hint. The scissors and edge are aria-hidden. Under prefers-reduced-motion the coupon is cut out at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Coupon" input.
Visual behavior: A promo code on a coupon: a valid code sends scissors round the dashed edge, cutting it out so it lifts off the page; a wrong one makes it shake.
Technique:
- The dashed edge is an SVG rect. The cut is a solid path on the same outline drawn in by its dash offset while scissors ride that path on offset-path, their blades snipping.
- A small state machine (idle, cut, done, error) sits on a data attribute. When the scissors finish, the piece lifts and tilts above an empty hole; a wrong code plays a shake and returns to idle when it ends.
Accessibility: A form with a labelled input and a submit button; the result is read out from a role="status" line that also holds the hint. The scissors and edge are aria-hidden. Under prefers-reduced-motion the coupon is cut out at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A promo code on a coupon: a valid code sends scissors round the dashed edge, cutting it out so it lifts off the page; a wrong one makes it shake.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, click, draw
- Techniques
- keyframes, stroke dashoffset, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A form with a labelled input and a submit button; the result is read out from a role="status" line that also holds the hint. The scissors and edge are aria-hidden. Under prefers-reduced-motion the coupon is cut out at once.