Piggy bank
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPiggy(el) on the mounted element instead.
<div class="field-piggy">
<svg class="field-piggy__art" viewBox="0 0 150 112" aria-hidden="true">
<g class="field-piggy__size" style="transform:scale(1.000)">
<g class="field-piggy__pig">
<rect class="field-piggy__dark" x="40" y="84" width="10" height="18" rx="3"></rect>
<rect class="field-piggy__dark" x="92" y="84" width="10" height="18" rx="3"></rect>
<path class="field-piggy__tail" d="M31 60Q22 58 24 52Q26 47 30 51Q32 55 26 57"></path>
<ellipse class="field-piggy__skin" cx="70" cy="64" rx="42" ry="31"></ellipse>
<rect class="field-piggy__skin" x="50" y="84" width="10" height="18" rx="3"></rect>
<rect class="field-piggy__skin" x="82" y="84" width="10" height="18" rx="3"></rect>
<path class="field-piggy__dark" d="M86 38L96 22L102 42Z"></path>
<ellipse class="field-piggy__snout" cx="113" cy="66" rx="8" ry="10"></ellipse>
<ellipse class="field-piggy__nostril" cx="111.5" cy="62.5" rx="1.4" ry="2.2"></ellipse>
<ellipse class="field-piggy__nostril" cx="114.5" cy="69.5" rx="1.4" ry="2.2"></ellipse>
<circle class="field-piggy__eye" cx="99" cy="54" r="2.4"></circle>
<rect class="field-piggy__slot" x="60" y="33.5" width="20" height="4" rx="2"></rect>
</g>
</g>
</svg>
<label class="field-piggy__label" for="field-piggy">Monthly saving</label>
<div class="field-piggy__field">
<span class="field-piggy__cur" aria-hidden="true">$</span>
<input id="field-piggy" class="field-piggy__input" inputMode="numeric" placeholder="0" autoComplete="off" value=""/>
</div>
</div>/* 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; }
}function initFieldPiggy(root) {
var NS = 'http://www.w3.org/2000/svg';
var input = root.querySelector('.field-piggy__input');
var size = root.querySelector('.field-piggy__size');
var pig = root.querySelector('.field-piggy__pig');
var v = '';
var jig = '';
// One coin per digit: it drops into the slot when the amount grows and spins back out when it shrinks.
function coin(out) {
var g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'field-piggy__coin field-piggy__coin--' + (out ? 'out' : 'in'));
g.innerHTML = '<circle class="field-piggy__face" cx="70" cy="28" r="7.5"></circle><circle class="field-piggy__rim" cx="70" cy="28" r="5"></circle>';
g.addEventListener('animationend', function () {
g.remove();
});
size.appendChild(g);
var coins = size.querySelectorAll('.field-piggy__coin');
if (coins.length > 6) coins[0].remove();
}
function onInput() {
var next = input.value
.replace(/\D/g, '')
.replace(/^0+(?=\d)/, '')
.slice(0, 6);
if (input.value !== next) input.value = next;
if (next === v) return;
coin(next.length < v.length);
jig = jig === 'a' ? 'b' : 'a';
pig.setAttribute('data-jig', jig);
v = next;
// The pig fattens as the amount rises.
size.style.transform = 'scale(' + (1 + (Math.min(Number(v) || 0, 1000) / 1000) * 0.18).toFixed(3) + ')';
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-piggy').forEach(initFieldPiggy);Goal: Create a "Piggy bank" input.
Visual behavior: 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.
Technique:
- Each change adds a coin that plays a drop keyframe, turning edge-on to slip into the slot, or a spin-out keyframe when a digit is deleted, and removes itself when it ends.
- The pig jiggles on two alternating keyframes so it replays every time, and scales up from its feet with the amount on a springy transition.
Accessibility: 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.
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 "Piggy bank" input.
Visual behavior: 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.
Technique:
- Each change adds a coin that plays a drop keyframe, turning edge-on to slip into the slot, or a spin-out keyframe when a digit is deleted, and removes itself when it ends.
- The pig jiggles on two alternating keyframes so it replays every time, and scales up from its feet with the amount on a springy transition.
Accessibility: 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.
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 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.