Price tag

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

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

Install with shadcn

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

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.

More inputs

See all

Up to 5 kg

Kitchen scale
Skywriter
Card number
Type a 24-hour time
Dial time