Coupon

Try SAVE20

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-coupon
Source on GitHub

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.

More inputs

See all
Label maker
Letter board

Mailbox
Name tag