Piggy bank

No dependencies
Code format
React + Tailwind
import { useId, useRef, useState } from 'react';

const css = `
  /* A coin drops, turns edge-on over the slot and slips in; a deleted digit spins one back out. */
  @keyframes field-piggy-in {
    0% { transform: translateY(-38px) scaleX(1); opacity: 0; }
    12% { opacity: 1; }
    62% { transform: translateY(-4px) scaleX(0.2); opacity: 1; }
    100% { transform: translateY(6px) scaleX(0.2); opacity: 0; }
  }
  @keyframes field-piggy-out {
    0% { transform: translate(0, 4px) rotate(0deg) scaleX(0.2); opacity: 0; }
    20% { transform: translate(2px, -8px) rotate(20deg) scaleX(1); opacity: 1; }
    100% { transform: translate(34px, -30px) rotate(220deg) scaleX(1); opacity: 0; }
  }
  @keyframes field-piggy-jiggle-a { 0%, 100% { transform: scale(1, 1); } 30% { transform: scale(1.04, 0.93); } 60% { transform: scale(0.98, 1.03); } }
  @keyframes field-piggy-jiggle-b { 0%, 100% { transform: scale(1, 1); } 30% { transform: scale(1.04, 0.93); } 60% { transform: scale(0.98, 1.03); } }
  .field-piggy { width: 240px; }
  .field-piggy__art { display: block; width: 150px; height: 112px; margin: 0 auto 8px; overflow: visible; }
  /* The pig fattens as the amount rises. */
  .field-piggy__size { transform-box: view-box; transform-origin: 72px 102px; transition: transform 0.5s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .field-piggy__pig { transform-box: view-box; transform-origin: 72px 102px; }
  .field-piggy__pig[data-jig='a'] { animation: field-piggy-jiggle-a 0.35s ease-out 0.2s; }
  .field-piggy__pig[data-jig='b'] { animation: field-piggy-jiggle-b 0.35s ease-out 0.2s; }
  .field-piggy__skin { fill: light-dark(#5f626a, #a7abb2); }
  .field-piggy__dark { fill: light-dark(#6d7179, #7d8087); }
  .field-piggy__snout { fill: light-dark(#50535b, #c6c8cd); }
  .field-piggy__nostril { fill: light-dark(#cbced3, #393c41); }
  .field-piggy__eye { fill: light-dark(#fbfcfd, #121417); }
  .field-piggy__tail { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.5; stroke-linecap: round; }
  .field-piggy__slot { fill: light-dark(#373a40, #121417); }
  .field-piggy__coin { transform-box: fill-box; transform-origin: center; }
  .field-piggy__coin--in { animation: field-piggy-in 0.45s ease-in forwards; }
  .field-piggy__coin--out { animation: field-piggy-out 0.6s ease-out forwards; }
  .field-piggy__face { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
  .field-piggy__rim { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; }
  .field-piggy__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-piggy__field { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 14px; border: 1px solid light-dark(#dbdde1, #2e3035); border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#5f626a, #a7abb2); transition: border-color 0.2s; }
  .field-piggy__field:focus-within { border-color: light-dark(#5f626a, #a7abb2); }
  .field-piggy__cur { font: 600 18px system-ui, sans-serif; }
  .field-piggy__input { flex: 1; min-width: 0; margin: 0; padding: 0; border: 0; outline: none; background: transparent; color: light-dark(#373a40, #f1f2f3); font: 600 18px system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  .field-piggy__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  @media (prefers-reduced-motion: reduce) {
    .field-piggy__size { transition: none; }
    .field-piggy__pig[data-jig], .field-piggy__coin { animation: none; opacity: 0; }
  }
`;

type Coin = { id: number; out: boolean };

export function PiggyInput() {
  const id = useId();
  const [v, setV] = useState('');
  const [coins, setCoins] = useState<Coin[]>([]);
  const [jig, setJig] = useState<'a' | 'b' | undefined>();
  const next = useRef(0);
  const amount = Number(v) || 0;
  return (
    <>
      <style>
        {css}
      </style>
      <div className="field-piggy">
        <svg className="field-piggy__art" viewBox="0 0 150 112" aria-hidden="true">
          <g className="field-piggy__size" style={{ transform: `scale(${(1 + (Math.min(amount, 1000) / 1000) * 0.18).toFixed(3)})` }}>
            <g className="field-piggy__pig" data-jig={jig}>
              <rect className="field-piggy__dark" x="40" y="84" width="10" height="18" rx="3" />
              <rect className="field-piggy__dark" x="92" y="84" width="10" height="18" rx="3" />
              <path className="field-piggy__tail" d="M31 60Q22 58 24 52Q26 47 30 51Q32 55 26 57" />
              <ellipse className="field-piggy__skin" cx="70" cy="64" rx="42" ry="31" />
              <rect className="field-piggy__skin" x="50" y="84" width="10" height="18" rx="3" />
              <rect className="field-piggy__skin" x="82" y="84" width="10" height="18" rx="3" />
              <path className="field-piggy__dark" d="M86 38L96 22L102 42Z" />
              <ellipse className="field-piggy__snout" cx="113" cy="66" rx="8" ry="10" />
              <ellipse className="field-piggy__nostril" cx="111.5" cy="62.5" rx="1.4" ry="2.2" />
              <ellipse className="field-piggy__nostril" cx="114.5" cy="69.5" rx="1.4" ry="2.2" />
              <circle className="field-piggy__eye" cx="99" cy="54" r="2.4" />
              <rect className="field-piggy__slot" x="60" y="33.5" width="20" height="4" rx="2" />
            </g>
            {coins.map((c) => (
              <g key={c.id} className={`field-piggy__coin field-piggy__coin--${c.out ? 'out' : 'in'}`} onAnimationEnd={() => setCoins((list) => list.filter((k) => k.id !== c.id))}>
                <circle className="field-piggy__face" cx="70" cy="28" r="7.5" />
                <circle className="field-piggy__rim" cx="70" cy="28" r="5" />
              </g>
            ))}
          </g>
        </svg>
        <label className="field-piggy__label" htmlFor={id}>
          Monthly saving
        </label>
        <div className="field-piggy__field">
          <span className="field-piggy__cur" aria-hidden="true">
            $
          </span>
          <input
            id={id}
            className="field-piggy__input"
            value={v}
            inputMode="numeric"
            placeholder="0"
            autoComplete="off"
            onChange={(e) => {
              const nextV = e.target.value
                .replace(/\D/g, '')
                .replace(/^0+(?=\d)/, '')
                .slice(0, 6);
              if (nextV === v) return;
              // One coin per digit: in when it grows, back out when it shrinks.
              setCoins((list) => [...list.slice(-5), { id: next.current++, out: nextV.length < v.length }]);
              setJig((j) => (j === 'a' ? 'b' : 'a'));
              setV(nextV);
            }}
          />
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A savings amount with a piggy bank: every digit drops a coin in the slot with a jiggle, deleting pops one back out, and the pig grows rounder as the amount rises.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, spring, keyframe
Techniques
keyframes, css transition, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real input with inputmode="numeric" and a visible <label>; the pig and coins are aria-hidden. Under prefers-reduced-motion no coins fall and the pig does not jiggle.

More inputs

See all
Price tag

Up to 5 kg

Kitchen scale
Skywriter
Card number