Up to 5 kg
Price tag
import { useId, useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
/* A damped swing on the string; --dir sends it right for a new digit, left for a deleted one. */
@keyframes field-pricetag-swing-a {
0% { transform: rotate(0deg); }
14% { transform: rotate(calc(var(--dir) * 9deg)); }
34% { transform: rotate(calc(var(--dir) * -6deg)); }
54% { transform: rotate(calc(var(--dir) * 3.5deg)); }
74% { transform: rotate(calc(var(--dir) * -1.5deg)); }
100% { transform: rotate(0deg); }
}
@keyframes field-pricetag-swing-b {
0% { transform: rotate(0deg); }
14% { transform: rotate(calc(var(--dir) * 9deg)); }
34% { transform: rotate(calc(var(--dir) * -6deg)); }
54% { transform: rotate(calc(var(--dir) * 3.5deg)); }
74% { transform: rotate(calc(var(--dir) * -1.5deg)); }
100% { transform: rotate(0deg); }
}
.field-pricetag { position: relative; width: 260px; height: 168px; }
.field-pricetag__nail { position: absolute; left: 50%; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); box-shadow: inset -2px -2px 0 light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
.field-pricetag__hang { position: absolute; left: 50%; top: 7px; transform: translateX(-50%); }
.field-pricetag__swing { position: relative; display: flex; flex-direction: column; align-items: center; transform-origin: 50% 0; filter: drop-shadow(0 4px 6px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
.field-pricetag__swing[data-swing='a'] { animation: field-pricetag-swing-a 1.2s ease-in-out; }
.field-pricetag__swing[data-swing='b'] { animation: field-pricetag-swing-b 1.2s ease-in-out; }
.field-pricetag__string { position: relative; z-index: 1; width: 1.5px; height: 40px; margin-bottom: -18px; background: light-dark(#50535b, #c6c8cd); }
/* Card stock stays pale in both themes, with a punched hole the string runs through. */
.field-pricetag__tag {
min-width: 120px;
box-sizing: border-box;
padding: 30px 20px 14px;
background: light-dark(#ebedf0, #e5e6e9);
clip-path: polygon(24px 0, calc(100% - 24px) 0, 100% 24px, 100% 100%, 0 100%, 0 24px);
-webkit-mask: radial-gradient(circle at 50% 15px, transparent 4.5px, #000 5px);
mask: radial-gradient(circle at 50% 15px, transparent 4.5px, #000 5px);
text-align: center;
}
.field-pricetag__label { display: block; color: light-dark(#5f626a, #393c41); font: 600 10px system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; }
.field-pricetag__amount { display: inline-flex; align-items: baseline; justify-content: center; border-bottom: 2px solid transparent; color: light-dark(#373a40, #232529); transition: border-color 0.2s; }
.field-pricetag__amount:focus-within { border-bottom-color: light-dark(#6d7179, #7d8087); }
.field-pricetag__cur { font: 600 22px system-ui, sans-serif; }
.field-pricetag__input { width: 2ch; min-width: 1.5ch; padding: 0; margin: 0; border: 0; outline: none; background: transparent; color: inherit; caret-color: currentColor; font: 700 34px/1.2 system-ui, sans-serif; font-variant-numeric: tabular-nums; transition: width 0.15s; }
.field-pricetag__input::placeholder { color: light-dark(#cbced3, #7d8087); }
@media (prefers-reduced-motion: reduce) {
.field-pricetag__swing[data-swing] { animation: none; }
.field-pricetag__input { transition: none; }
}
`;
export function PricetagInput() {
const id = useId();
const [v, setV] = useState('');
const [swing, setSwing] = useState<{ k: 'a' | 'b'; dir: number }>();
return (
<>
<style>
{css}
</style>
<div className="field-pricetag">
<span className="field-pricetag__nail" aria-hidden="true" />
<div className="field-pricetag__hang">
<div className="field-pricetag__swing" data-swing={swing?.k} style={{ '--dir': swing?.dir ?? 1 } as CSSProperties}>
<span className="field-pricetag__string" aria-hidden="true" />
<div className="field-pricetag__tag">
<label className="field-pricetag__label" htmlFor={id}>
Price
</label>
<span className="field-pricetag__amount">
<span className="field-pricetag__cur" aria-hidden="true">
$
</span>
<input
id={id}
className="field-pricetag__input"
value={v}
inputMode="decimal"
placeholder="0"
autoComplete="off"
style={{ width: `${Math.max(v.length, 1) + 0.3}ch` }}
onChange={(e) => {
// Digits and one point, two decimals, up to six figures.
const [whole = '', ...rest] = e.target.value.replace(/[^\d.]/g, '').split('.');
const next = whole.slice(0, 6) + (rest.length ? `.${rest.join('').slice(0, 2)}` : '');
if (next === v) return;
setSwing((s) => ({ k: s?.k === 'a' ? 'b' : 'a', dir: next.length < v.length ? -1 : 1 }));
setV(next);
}}
/>
</span>
</div>
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPricetag(el) on the mounted element instead.
<div class="field-pricetag">
<span class="field-pricetag__nail" aria-hidden="true"></span>
<div class="field-pricetag__hang">
<div class="field-pricetag__swing" style="--dir:1">
<span class="field-pricetag__string" aria-hidden="true"></span>
<div class="field-pricetag__tag">
<label class="field-pricetag__label" for="field-pricetag">Price</label>
<span class="field-pricetag__amount">
<span class="field-pricetag__cur" aria-hidden="true">$</span>
<input id="field-pricetag" class="field-pricetag__input" inputMode="decimal" placeholder="0" autoComplete="off" style="width:1.3ch" value=""/>
</span>
</div>
</div>
</div>
</div>/* A damped swing on the string; --dir sends it right for a new digit, left for a deleted one. */
@keyframes field-pricetag-swing-a {
0% { transform: rotate(0deg); }
14% { transform: rotate(calc(var(--dir) * 9deg)); }
34% { transform: rotate(calc(var(--dir) * -6deg)); }
54% { transform: rotate(calc(var(--dir) * 3.5deg)); }
74% { transform: rotate(calc(var(--dir) * -1.5deg)); }
100% { transform: rotate(0deg); }
}
@keyframes field-pricetag-swing-b {
0% { transform: rotate(0deg); }
14% { transform: rotate(calc(var(--dir) * 9deg)); }
34% { transform: rotate(calc(var(--dir) * -6deg)); }
54% { transform: rotate(calc(var(--dir) * 3.5deg)); }
74% { transform: rotate(calc(var(--dir) * -1.5deg)); }
100% { transform: rotate(0deg); }
}
.field-pricetag { position: relative; width: 260px; height: 168px; }
.field-pricetag__nail { position: absolute; left: 50%; top: 2px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); box-shadow: inset -2px -2px 0 light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
.field-pricetag__hang { position: absolute; left: 50%; top: 7px; transform: translateX(-50%); }
.field-pricetag__swing { position: relative; display: flex; flex-direction: column; align-items: center; transform-origin: 50% 0; filter: drop-shadow(0 4px 6px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
.field-pricetag__swing[data-swing='a'] { animation: field-pricetag-swing-a 1.2s ease-in-out; }
.field-pricetag__swing[data-swing='b'] { animation: field-pricetag-swing-b 1.2s ease-in-out; }
.field-pricetag__string { position: relative; z-index: 1; width: 1.5px; height: 40px; margin-bottom: -18px; background: light-dark(#50535b, #c6c8cd); }
/* Card stock stays pale in both themes, with a punched hole the string runs through. */
.field-pricetag__tag {
min-width: 120px;
box-sizing: border-box;
padding: 30px 20px 14px;
background: light-dark(#ebedf0, #e5e6e9);
clip-path: polygon(24px 0, calc(100% - 24px) 0, 100% 24px, 100% 100%, 0 100%, 0 24px);
-webkit-mask: radial-gradient(circle at 50% 15px, transparent 4.5px, #000 5px);
mask: radial-gradient(circle at 50% 15px, transparent 4.5px, #000 5px);
text-align: center;
}
.field-pricetag__label { display: block; color: light-dark(#5f626a, #393c41); font: 600 10px system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; }
.field-pricetag__amount { display: inline-flex; align-items: baseline; justify-content: center; border-bottom: 2px solid transparent; color: light-dark(#373a40, #232529); transition: border-color 0.2s; }
.field-pricetag__amount:focus-within { border-bottom-color: light-dark(#6d7179, #7d8087); }
.field-pricetag__cur { font: 600 22px system-ui, sans-serif; }
.field-pricetag__input { width: 2ch; min-width: 1.5ch; padding: 0; margin: 0; border: 0; outline: none; background: transparent; color: inherit; caret-color: currentColor; font: 700 34px/1.2 system-ui, sans-serif; font-variant-numeric: tabular-nums; transition: width 0.15s; }
.field-pricetag__input::placeholder { color: light-dark(#cbced3, #7d8087); }
@media (prefers-reduced-motion: reduce) {
.field-pricetag__swing[data-swing] { animation: none; }
.field-pricetag__input { transition: none; }
}function initFieldPricetag(root) {
var input = root.querySelector('.field-pricetag__input');
var swing = root.querySelector('.field-pricetag__swing');
var v = '';
var k = '';
function onInput() {
// Digits and one point, two decimals, up to six figures.
var parts = input.value.replace(/[^\d.]/g, '').split('.');
var next = parts[0].slice(0, 6) + (parts.length > 1 ? '.' + parts.slice(1).join('').slice(0, 2) : '');
if (input.value !== next) input.value = next;
if (next === v) return;
// Swing right for a new digit, left for a deleted one; a/b restarts the swing every time.
k = k === 'a' ? 'b' : 'a';
swing.setAttribute('data-swing', k);
swing.style.setProperty('--dir', next.length < v.length ? -1 : 1);
v = next;
input.style.width = Math.max(v.length, 1) + 0.3 + 'ch';
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-pricetag').forEach(initFieldPricetag);Goal: Create a "Price tag" input.
Visual behavior: A price field written on a hanging tag: the tag grows with the number and swings on its string with every digit, the other way when you delete.
Technique:
- The tag shape is a clip-path polygon with angled shoulders and the hole is a radial-gradient mask; a drop-shadow filter on the wrapper follows the cut shape. The input's width is set in ch from its length, so the tag grows.
- Every change alternates between two identical damped swing keyframes so the swing restarts, and a --dir custom property sends it right for a new digit and left for a deleted one.
Accessibility: A real input with inputmode="decimal" and a visible <label>; it accepts digits and one point. Focus underlines the amount. Under prefers-reduced-motion the tag does not swing.
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 "Price tag" input.
Visual behavior: A price field written on a hanging tag: the tag grows with the number and swings on its string with every digit, the other way when you delete.
Technique:
- The tag shape is a clip-path polygon with angled shoulders and the hole is a radial-gradient mask; a drop-shadow filter on the wrapper follows the cut shape. The input's width is set in ch from its length, so the tag grows.
- Every change alternates between two identical damped swing keyframes so the swing restarts, and a --dir custom property sends it right for a new digit and left for a deleted one.
Accessibility: A real input with inputmode="decimal" and a visible <label>; it accepts digits and one point. Focus underlines the amount. Under prefers-reduced-motion the tag does not swing.
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 price field written on a hanging tag: the tag grows with the number and swings on its string with every digit, the other way when you delete.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring
- Techniques
- keyframes, clip path, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real input with inputmode="decimal" and a visible <label>; it accepts digits and one point. Focus underlines the amount. Under prefers-reduced-motion the tag does not swing.