@daformat/react-number-flow-input 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +298 -0
  3. package/dist/NumberFlowInput.d.ts +60 -0
  4. package/dist/NumberFlowInput.js +3099 -0
  5. package/dist/NumberFlowInput.js.map +1 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/styles.d.ts +5 -0
  10. package/dist/styles.js +140 -0
  11. package/dist/styles.js.map +1 -0
  12. package/dist/utils/barrelWheel.d.ts +12 -0
  13. package/dist/utils/barrelWheel.js +83 -0
  14. package/dist/utils/barrelWheel.js.map +1 -0
  15. package/dist/utils/changes.d.ts +87 -0
  16. package/dist/utils/changes.js +794 -0
  17. package/dist/utils/changes.js.map +1 -0
  18. package/dist/utils/combineRefs.d.ts +5 -0
  19. package/dist/utils/combineRefs.js +16 -0
  20. package/dist/utils/combineRefs.js.map +1 -0
  21. package/dist/utils/cssEasing.d.ts +24 -0
  22. package/dist/utils/cssEasing.js +25 -0
  23. package/dist/utils/cssEasing.js.map +1 -0
  24. package/dist/utils/formatting.d.ts +33 -0
  25. package/dist/utils/formatting.js +99 -0
  26. package/dist/utils/formatting.js.map +1 -0
  27. package/dist/utils/maybe.d.ts +3 -0
  28. package/dist/utils/maybe.js +2 -0
  29. package/dist/utils/maybe.js.map +1 -0
  30. package/dist/utils/moveElementPreservingAnimation.d.ts +6 -0
  31. package/dist/utils/moveElementPreservingAnimation.js +61 -0
  32. package/dist/utils/moveElementPreservingAnimation.js.map +1 -0
  33. package/dist/utils/nullable.d.ts +12 -0
  34. package/dist/utils/nullable.js +19 -0
  35. package/dist/utils/nullable.js.map +1 -0
  36. package/dist/utils/textCleaning.d.ts +6 -0
  37. package/dist/utils/textCleaning.js +60 -0
  38. package/dist/utils/textCleaning.js.map +1 -0
  39. package/dist/utils/utils.d.ts +33 -0
  40. package/dist/utils/utils.js +162 -0
  41. package/dist/utils/utils.js.map +1 -0
  42. package/package.json +68 -0
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Zero-Clause BSD
2
+
3
+ Copyright (c) 2026 Mathieu Jouhet
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for
6
+ any purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
9
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
10
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
11
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
12
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
13
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
14
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # React number flow input
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/%40daformat%2Freact-number-flow-input)](https://www.npmjs.com/package/@daformat/react-number-flow-input)
4
+ [![NPM Downloads](https://img.shields.io/npm/dm/%40daformat%2Freact-number-flow-input)](https://www.npmjs.com/package/@daformat/react-number-flow-input)
5
+ [![Follow daformat on GitHub](https://img.shields.io/github/followers/daformat?label=Follow%20%40daformat&style=social)](https://github.com/daformat)
6
+ [![Follow daformat on X](https://img.shields.io/twitter/follow/daformat?label=Follow%20%40daformat&style=social)](https://twitter.com/daformat)
7
+
8
+ A zero-dependency React component that renders an animated number input. Digits animate in as they are typed, selecting and replacing a single digit gives you the popular barrel-wheel effect made famous by [NumberFlow](https://number-flow.barvian.me/), and external `value` changes animate as a coordinated barrel-wheel roll across every digit.
9
+
10
+ ## Demo
11
+
12
+ <https://hello-mat.com/design-engineering/component/number-flow-input>
13
+
14
+ ## Features
15
+
16
+ - **Zero runtime dependencies** — peer-depends on React `>= 18`, nothing else.
17
+ - **Two synchronized inputs** — a `contenteditable` for the animated display and a hidden `<input>` for native form integration (`name`, `form`, `required`, ...).
18
+ - **Controlled or uncontrolled** — use `value` or `defaultValue`.
19
+ - **Locale-aware formatting** — optional `Intl.NumberFormat` thousand separators and locale decimal characters.
20
+ - **Smart editing** — undo/redo, copy/cut/paste, decimal-scale clamping, max-length, negative numbers, leading-zero handling, etc.
21
+ - **Custom validation** — `isAllowed(value)` predicate to reject values you don't like.
22
+ - **Animations included** — digit flow-in, barrel-wheel digit rolls, separator slide-in/out, width animation on group changes.
23
+ - **Styles auto-injected** — a `<style>` tag is added to `<head>` on first mount, no CSS import required. SSR-safe.
24
+ - **Fully typed** — ships with TypeScript types.
25
+ - **Well tested** — 228+ unit and integration tests.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install @daformat/react-number-flow-input
31
+ ```
32
+
33
+ ```bash
34
+ yarn add @daformat/react-number-flow-input
35
+ ```
36
+
37
+ ```bash
38
+ pnpm add @daformat/react-number-flow-input
39
+ ```
40
+
41
+ ```bash
42
+ bun add @daformat/react-number-flow-input
43
+ ```
44
+
45
+ ```bash
46
+ deno add npm:@daformat/react-number-flow-input
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```tsx
52
+ import { NumberFlowInput } from "@daformat/react-number-flow-input";
53
+
54
+ export function Example() {
55
+ return (
56
+ <NumberFlowInput
57
+ defaultValue={1234}
58
+ format
59
+ onChange={(value) => console.log(value)}
60
+ />
61
+ );
62
+ }
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Uncontrolled
68
+
69
+ ```tsx
70
+ <NumberFlowInput defaultValue={42} onChange={(value) => console.log(value)} />
71
+ ```
72
+
73
+ ### Controlled
74
+
75
+ ```tsx
76
+ import { useState } from "react";
77
+ import { NumberFlowInput } from "@daformat/react-number-flow-input";
78
+
79
+ function Controlled() {
80
+ const [value, setValue] = useState<number | undefined>(0);
81
+ return <NumberFlowInput value={value} onChange={setValue} />;
82
+ }
83
+ ```
84
+
85
+ External updates to `value` are diffed against the previous value and animate as a coordinated barrel-wheel roll. Initial mount never animates.
86
+
87
+ ### Formatted display
88
+
89
+ ```tsx
90
+ <NumberFlowInput format value={1234567} /> // → "1,234,567"
91
+ <NumberFlowInput format locale="de-DE" value={1234567} /> // → "1.234.567"
92
+ ```
93
+
94
+ ### Decimal scale & negative numbers
95
+
96
+ ```tsx
97
+ <NumberFlowInput allowNegative decimalScale={2} defaultValue={-1234.5} format />
98
+ ```
99
+
100
+ `decimalScale={0}` prevents the user from typing a decimal point at all. `decimalScale={n}` clamps the number of fractional digits.
101
+
102
+ ### Locale
103
+
104
+ ```tsx
105
+ <NumberFlowInput locale="fr-FR" defaultValue={1234.5} format />
106
+ // Renders "1 234,5" (or the locale's group separator).
107
+ ```
108
+
109
+ The component accepts both `.` and the locale's decimal separator as input — typing either one resolves to the locale's decimal in the display.
110
+
111
+ ### Custom validation
112
+
113
+ ```tsx
114
+ <NumberFlowInput
115
+ isAllowed={(value) => value == null || (value >= 0 && value <= 100)}
116
+ />
117
+ ```
118
+
119
+ Any keystroke that would produce a value outside the allowed range is rejected and never reaches `onChange`.
120
+
121
+ ### Length limit
122
+
123
+ ```tsx
124
+ <NumberFlowInput maxLength={6} />
125
+ ```
126
+
127
+ ### Form integration
128
+
129
+ The component renders a hidden `<input>` (offscreen, `readonly`) that mirrors the current numeric value, so it participates in native form submissions:
130
+
131
+ ```tsx
132
+ <form action="/submit" method="post">
133
+ <NumberFlowInput name="price" required min={0} max={9999} defaultValue={0} />
134
+ <button type="submit">Save</button>
135
+ </form>
136
+ ```
137
+
138
+ `name`, `form`, `required`, `min`, `max`, `minLength` and `maxLength` are forwarded to the hidden input.
139
+
140
+ ### Auto focus / events
141
+
142
+ ```tsx
143
+ <NumberFlowInput
144
+ autoFocus
145
+ onFocus={() => console.log("focused")}
146
+ onBlur={() => console.log("blurred")}
147
+ />
148
+ ```
149
+
150
+ ### Ref
151
+
152
+ The `ref` is forwarded to the `contenteditable` element:
153
+
154
+ ```tsx
155
+ const ref = useRef<HTMLElement>(null);
156
+ <NumberFlowInput ref={ref} />;
157
+ ```
158
+
159
+ ## API
160
+
161
+ ```ts
162
+ import type {
163
+ NumberFlowInputProps,
164
+ NumberFlowInputCommonProps,
165
+ NumberFlowInputControlledProps,
166
+ NumberFlowInputUncontrolledProps,
167
+ } from "@daformat/react-number-flow-input";
168
+ ```
169
+
170
+ ### Value props
171
+
172
+ | Prop | Type | Description |
173
+ | -------------- | --------------------- | ------------------------------------------------------------------------------------------------------- |
174
+ | `value` | `number \| undefined` | Controlled value. When provided, changes animate as a barrel-wheel roll (except on initial mount). |
175
+ | `defaultValue` | `number` | Uncontrolled starting value. |
176
+ | `onChange` | `(value) => void` | Called with the parsed number (or `undefined` for intermediate states like `""`, `"-"`, `"."`, `"-."`). |
177
+
178
+ > `value` and `defaultValue` are mutually exclusive — TypeScript will enforce this.
179
+
180
+ ### Formatting
181
+
182
+ | Prop | Type | Default | Description |
183
+ | -------------------- | ----------------------- | ------- | ------------------------------------------------------------------------------- |
184
+ | `format` | `boolean` | `false` | When true, the display uses `Intl.NumberFormat` grouping. |
185
+ | `locale` | `string \| Intl.Locale` | — | Locale used for decimal and group separators. Defaults to the runtime's locale. |
186
+ | `decimalScale` | `number` | — | Max number of fractional digits. `0` forbids a decimal point entirely. |
187
+ | `autoAddLeadingZero` | `boolean` | `false` | Convert leading `.5` → `0.5` (and `-.5` → `-0.5`) automatically. |
188
+ | `allowNegative` | `boolean` | `false` | Allow typing a leading `-` to enter negative numbers. |
189
+
190
+ ### Editing constraints
191
+
192
+ | Prop | Type | Description |
193
+ | ----------- | --------------------------------- | -------------------------------------------------------------------------- |
194
+ | `maxLength` | `number` | Maximum raw length the user can type (counted before formatting). |
195
+ | `minLength` | `number` | Forwarded to the hidden `<input>` for form validation. |
196
+ | `min`/`max` | `number` | Forwarded to the hidden `<input>` for form validation. |
197
+ | `isAllowed` | `(value: number \| null) => bool` | Predicate that gates every change. Return `false` to reject the keystroke. |
198
+
199
+ ### DOM / form passthroughs
200
+
201
+ `id`, `name`, `form`, `required`, `placeholder`, `className`, `style`, `onFocus`, `onBlur`, `autoFocus`. `className` and `style` are applied to the root wrapper `<span>`.
202
+
203
+ ## Styling
204
+
205
+ Styles are injected globally on first mount. Every selector is scoped to `[data-numberflow-input-root]`, so they won't leak into your app.
206
+
207
+ The DOM structure (simplified):
208
+
209
+ ```html
210
+ <span data-numberflow-input-root class="{className}">
211
+ <span data-numberflow-input-wrapper>
212
+ <span
213
+ role="textbox"
214
+ contenteditable="true"
215
+ data-numberflow-input-contenteditable
216
+ data-placeholder="{placeholder}"
217
+ >
218
+ <span data-char-index="0" data-flow data-show>1</span>
219
+ <span data-char-index="1">,</span>
220
+ <!-- ...one span per character... -->
221
+ </span>
222
+ <input data-numberflow-input-real-input type="string" readonly />
223
+ <!-- barrel-wheel overlays are appended here while animating -->
224
+ </span>
225
+ </span>
226
+ ```
227
+
228
+ You can target any of the above data attributes to customize the look:
229
+
230
+ ```css
231
+ [data-numberflow-input-contenteditable] {
232
+ font-variant-numeric: tabular-nums;
233
+ font-feature-settings: "tnum";
234
+ }
235
+
236
+ [data-numberflow-input-contenteditable]:empty::before {
237
+ color: #999; /* placeholder color */
238
+ }
239
+ ```
240
+
241
+ Animation timings live in the injected stylesheet and use `cubic-bezier(.215, .61, .355, 1)` (ease-out-cubic). The flow-in animation is `0.2s`; the barrel-wheel roll and width animation are `0.4s`.
242
+
243
+ ## Server-side rendering
244
+
245
+ `injectStyles()` is a no-op on the server and idempotent on the client. The component itself only touches the DOM inside `useInsertionEffect` / `useEffect`, so it renders cleanly in Next.js, Remix and other SSR frameworks.
246
+
247
+ ## Browser support
248
+
249
+ Modern evergreen browsers. Required browser features:
250
+
251
+ - `Intl.NumberFormat` (for `format` / `locale`)
252
+ - Web Animations API (`element.animate(...)`) — used for the barrel-wheel and position animations
253
+ - CSS `transition` + `transform` — used for flow-in animation
254
+ - `requestAnimationFrame`, `ResizeObserver`
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ pnpm install
260
+ pnpm test # vitest run
261
+ pnpm build # tsc -p tsconfig.build.json
262
+ pnpm format # prettier --write .
263
+ pnpm lint:js # eslint .
264
+ ```
265
+
266
+ ### Project layout
267
+
268
+ ```
269
+ src/
270
+ ├── NumberFlowInput.tsx # The component
271
+ ├── styles.ts # Injected stylesheet
272
+ ├── index.ts # Public entry point
273
+ └── utils/
274
+ ├── barrelWheel.ts # Wheel DOM helpers
275
+ ├── changes.ts # Diffing (typing & replacement)
276
+ ├── combineRefs.ts # Ref forwarding helper
277
+ ├── cssEasing.ts # Cubic-bezier tokens
278
+ ├── formatting.ts # Intl.NumberFormat wrapper
279
+ ├── moveElementPreservingAnimation.ts
280
+ ├── textCleaning.ts # Raw text sanitization
281
+ └── utils.ts # DOM/measurement helpers
282
+ ```
283
+
284
+ Every util has its own `*.test.ts` file next to it; component-level tests live in `src/NumberFlowInput.test.tsx`.
285
+
286
+ ## Contributing
287
+
288
+ Issues and pull requests are welcome at <https://github.com/daformat/react-number-flow-input>.
289
+
290
+ When opening a PR, please:
291
+
292
+ 1. Add a changeset (`pnpm changeset`) describing the change.
293
+ 2. Make sure `pnpm ci` passes locally (build + format check + tests).
294
+ 3. Add tests next to the code you touched — utils live in `src/utils/*.test.ts`, component-level behavior in `src/NumberFlowInput.test.tsx`.
295
+
296
+ ## License
297
+
298
+ [Zero-Clause BSD](./LICENSE) — do whatever you want with it.
@@ -0,0 +1,60 @@
1
+ import { type ComponentPropsWithoutRef } from "react";
2
+ import type { MaybeUndefined } from "./utils/maybe.js";
3
+ export type NumberFlowInputControlledProps = {
4
+ value: MaybeUndefined<number>;
5
+ defaultValue?: never;
6
+ };
7
+ export type NumberFlowInputUncontrolledProps = {
8
+ defaultValue?: number;
9
+ value?: never;
10
+ };
11
+ export type NumberFlowInputCommonProps = {
12
+ /**
13
+ * callback when the value changes
14
+ */
15
+ onChange?: (value: MaybeUndefined<number>) => void;
16
+ /**
17
+ * should the component add leading zero when the user types a decimal point?
18
+ */
19
+ autoAddLeadingZero?: boolean;
20
+ /**
21
+ * number of allowed decimal places
22
+ */
23
+ decimalScale?: number;
24
+ /**
25
+ * whether to allow negative values
26
+ */
27
+ allowNegative?: boolean;
28
+ /**
29
+ * maxLength of the input
30
+ */
31
+ maxLength?: number;
32
+ /**
33
+ * callback to determine if a value is allowed.
34
+ * If provided, the input will not allow values that are not allowed.
35
+ * The callback is called with the value as an argument.
36
+ * If the callback returns false, the input will be prevented from changing.
37
+ * If the callback returns true, the input will be allowed to change.
38
+ * If the callback is not provided, the input will not be restricted in value.
39
+ */
40
+ isAllowed?: (value: number | null) => boolean;
41
+ /**
42
+ * Focus the input on mount
43
+ */
44
+ autoFocus?: boolean;
45
+ /**
46
+ * Locale for number formatting.
47
+ * If provided, decimal and group separators will be inferred from this locale.
48
+ * The input will accept both '.' and the locale-specific decimal separator.
49
+ */
50
+ locale?: Intl.UnicodeBCP47LocaleIdentifier | Intl.Locale;
51
+ /**
52
+ * Whether to format the display using Intl.NumberFormat.
53
+ * If true with locale, uses the specified locale.
54
+ * If true without locale, uses the browser's default locale.
55
+ * Default: false (no formatting)
56
+ */
57
+ format?: boolean;
58
+ } & Pick<ComponentPropsWithoutRef<"input">, "min" | "max" | "minLength" | "maxLength" | "form" | "required" | "name" | "id" | "placeholder" | "onFocus" | "onBlur" | "className" | "style">;
59
+ export type NumberFlowInputProps = NumberFlowInputCommonProps & (NumberFlowInputControlledProps | NumberFlowInputUncontrolledProps);
60
+ export declare const NumberFlowInput: import("react").ForwardRefExoticComponent<NumberFlowInputProps & import("react").RefAttributes<HTMLElement>>;