Currency Input Format
Separators fade in where they belong as the amount groups itself, and the caret holds its place.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Currency Input Format
*
* An amount field that groups itself as it is typed. Each separator
* fades in where it belongs as the figure regroups around it, and the
* caret is put back where the typist left it — counted in digits rather
* than in string positions, which is the only measure that survives a
* separator being inserted to its left.
*
* The field stays a real `<input>` holding the formatted value; only its
* own glyphs are transparent, because the cells above it are what gets
* read. Both layers use the same monospace metrics, and a cell is keyed
* by its place in the row rather than by the digit standing in it, so
* every figure sits exactly where the native caret expects it — no cell
* is ever mid-flight under a caret the browser has already moved.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `symbol`, `defaultValue`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CurrencyInputFormatProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. */
label?: string;
/** Prefixed to the amount. */
symbol?: string;
/** Character between thousands. */
groupSeparator?: string;
/** Starting amount, digits and at most one decimal point. */
defaultValue?: string;
/** Most digits accepted before the decimal point. */
maxIntegerDigits?: number;
/** Focus ring colour. */
accent?: string;
/** Fires with the unformatted amount on every keystroke. */
onAmountChange?: (amount: string) => void;
};
type VariantConfig = {
/** Seconds a separator takes to arrive. */
separatorSeconds: number;
/** Font size of the amount, in px. */
fontSize: number;
slideSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: digits are text, so nothing here scales or bounces —
// the arriving separator fades, and a cell that does have to move (the
// row is re-measured when the figure size changes) translates on a
// spring well above a 0.8 damping ratio. An amount that overshoots its
// own grouping is unreadable at exactly the wrong moment.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Effectively instant regrouping. For a form full of amount fields.
subtle: {
separatorSeconds: 0.08,
fontSize: 18,
slideSpring: { type: "spring", stiffness: 850, damping: 57 },
},
// The regroup is visible without ever lagging the keystroke. ζ ≈ 0.97
// — the all-purpose setting.
default: {
separatorSeconds: 0.16,
fontSize: 22,
slideSpring: { type: "spring", stiffness: 520, damping: 44 },
},
// A softer slide on a larger figure, for a single prominent amount.
playful: {
separatorSeconds: 0.26,
fontSize: 27,
slideSpring: { type: "spring", stiffness: 350, damping: 32 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const AMOUNT_FONT =
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
/** Digits and at most one decimal point survive; everything else the
* typist produces is discarded before the value is grouped. */
function clean(raw: string, maxIntegerDigits: number) {
const kept = raw.replace(/[^\d.]/g, "");
const [head, ...tail] = kept.split(".");
const integer = head.replace(/^0+(?=\d)/, "").slice(0, maxIntegerDigits);
if (tail.length === 0) return integer;
return `${integer}.${tail.join("").slice(0, 2)}`;
}
function group(value: string, separator: string) {
const [integer, decimals] = value.split(".");
const grouped = (integer || "").replace(/\B(?=(\d{3})+(?!\d))/g, separator);
return decimals === undefined ? grouped : `${grouped}.${decimals}`;
}
/** Caret position measured the only way that survives regrouping: how
* many typed characters sit to its left, separators excluded. */
function typedBefore(text: string, caret: number, separator: string) {
let count = 0;
for (let index = 0; index < caret && index < text.length; index++) {
if (text[index] !== separator) count++;
}
return count;
}
function caretAfter(text: string, typedCount: number, separator: string) {
if (typedCount === 0) return 0;
let seen = 0;
for (let index = 0; index < text.length; index++) {
if (text[index] !== separator) seen++;
if (seen === typedCount) return index + 1;
}
return text.length;
}
export default function CurrencyInputFormat({
variant = "default",
label = "Transfer amount",
symbol = "$",
groupSeparator = ",",
defaultValue = "",
maxIntegerDigits = 9,
accent = "#5B5BD6",
onAmountChange,
}: CurrencyInputFormatProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [amount, setAmount] = useState(() => clean(defaultValue, maxIntegerDigits));
const [ring, setRing] = useState(false);
// Scoped per instance: a hard-coded id would break the label
// association the moment a page carried two amount fields.
const fieldId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const pendingCaret = useRef<number | null>(null);
const formatted = group(amount, groupSeparator);
// The caret is restored before paint, so it never appears at the end of
// the field for a frame after a separator is inserted.
useLayoutEffect(() => {
const target = pendingCaret.current;
if (target === null || !inputRef.current) return;
const position = caretAfter(formatted, target, groupSeparator);
inputRef.current.setSelectionRange(position, position);
pendingCaret.current = null;
}, [formatted, groupSeparator]);
// Cells are keyed by their distance from the right-hand end, which is
// where the row is anchored: slot three stays slot three whatever digit
// is standing in it. The figure grows leftward into new slots instead
// of every glyph being torn down and remounted on each keystroke.
const cells = [...formatted].map((character, index) => {
const fromRight = formatted.length - index;
const typedFromRight = [...formatted]
.slice(index)
.filter((entry) => entry !== groupSeparator).length;
const isSeparator = character === groupSeparator;
return {
character,
isSeparator,
key: isSeparator ? `sep-${fromRight}` : `cell-${typedFromRight}`,
};
});
return (
<div style={{ width: 288, color: "inherit" }}>
<label
htmlFor={fieldId}
style={{
display: "block",
marginBottom: 6,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.6,
}}
>
{label}
</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 13px",
borderRadius: 11,
border: `1px solid ${ring ? accent : tone(15)}`,
background: tone(6),
boxShadow: ring ? `0 0 0 3px ${tone(14)}` : "none",
}}
>
<span
aria-hidden
style={{
flex: "0 0 auto",
fontSize: cfg.fontSize * 0.72,
fontWeight: 650,
opacity: 0.45,
}}
>
{symbol}
</span>
<div
style={{
position: "relative",
flex: 1,
minWidth: 0,
height: cfg.fontSize * 1.35,
}}
>
{/* What gets read: one cell per character, right-aligned, so a
new digit extends the row leftwards and the separator lands
between two slots that were already there. */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
overflow: "hidden",
fontFamily: AMOUNT_FONT,
fontSize: cfg.fontSize,
fontWeight: 650,
letterSpacing: 0,
pointerEvents: "none",
}}
>
{formatted.length === 0 && (
<span style={{ opacity: 0.28 }}>0.00</span>
)}
<AnimatePresence initial={false}>
{cells.map((cell) => (
<motion.span
key={cell.key}
// Position only, never size: a figure is text, so it
// may be carried to a new place but must never be
// stretched to get there.
layout={reduceMotion ? false : "position"}
initial={
cell.isSeparator && !reduceMotion
? { opacity: 0 }
: { opacity: 1 }
}
animate={{ opacity: cell.isSeparator ? 0.5 : 1 }}
exit={{ opacity: 0 }}
transition={{
layout: reduceMotion ? { duration: 0 } : cfg.slideSpring,
opacity: {
duration: reduceMotion ? 0 : cfg.separatorSeconds,
ease: "easeOut",
},
}}
style={{ display: "inline-block", whiteSpace: "pre" }}
>
{cell.character}
</motion.span>
))}
</AnimatePresence>
</div>
<input
id={fieldId}
ref={inputRef}
inputMode="decimal"
value={formatted}
onChange={(event) => {
const element = event.target;
const raw = element.value;
const caret = element.selectionStart ?? raw.length;
pendingCaret.current = typedBefore(raw, caret, groupSeparator);
const next = clean(raw, maxIntegerDigits);
setAmount(next);
onAmountChange?.(next);
}}
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
style={{
position: "relative",
width: "100%",
height: "100%",
padding: 0,
margin: 0,
textAlign: "right",
fontFamily: AMOUNT_FONT,
fontSize: cfg.fontSize,
fontWeight: 650,
letterSpacing: 0,
// The input keeps the real value and the real caret; only
// its own glyphs are transparent, because the animated
// cells above are what gets read.
color: "transparent",
caretColor: "currentColor",
background: "transparent",
border: "none",
outline: "none",
}}
/>
</div>
</div>
<div
role="status"
style={{ marginTop: 7, fontSize: 11, opacity: 0.45, minHeight: 15 }}
>
{amount === ""
? "Type an amount to see it group itself"
: `${symbol}${formatted} will be transferred`}
</div>
</div>
);
}About this pattern
An amount field that groups itself without ever losing the typist. Each separator fades in where it belongs as the figure regroups around it, and the caret is put back before paint — counted in typed characters rather than in string positions, which is the only measure that survives a separator appearing to its left. The row is anchored at its right-hand end and its cells are keyed by slot rather than by glyph, so no figure is ever mid-flight under a caret the browser has already moved. Nothing scales and nothing bounces, and the field stays a real input holding the real value.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
Transfer amounts group live while the field stays editable mid-number.
Related patterns
- Range Double HandleTwo handles bound a range and the fill between them tracks both, one-to-one under the pointer and settling on a spring from the keyboard.
- Search Input ExpandA search icon opens into a full field while the controls beside it give up the space.
- Checkbox Check DrawThe box fills from its center and the tick strokes itself in over the fill.