@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.
- package/LICENSE +14 -0
- package/README.md +298 -0
- package/dist/NumberFlowInput.d.ts +60 -0
- package/dist/NumberFlowInput.js +3099 -0
- package/dist/NumberFlowInput.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.d.ts +5 -0
- package/dist/styles.js +140 -0
- package/dist/styles.js.map +1 -0
- package/dist/utils/barrelWheel.d.ts +12 -0
- package/dist/utils/barrelWheel.js +83 -0
- package/dist/utils/barrelWheel.js.map +1 -0
- package/dist/utils/changes.d.ts +87 -0
- package/dist/utils/changes.js +794 -0
- package/dist/utils/changes.js.map +1 -0
- package/dist/utils/combineRefs.d.ts +5 -0
- package/dist/utils/combineRefs.js +16 -0
- package/dist/utils/combineRefs.js.map +1 -0
- package/dist/utils/cssEasing.d.ts +24 -0
- package/dist/utils/cssEasing.js +25 -0
- package/dist/utils/cssEasing.js.map +1 -0
- package/dist/utils/formatting.d.ts +33 -0
- package/dist/utils/formatting.js +99 -0
- package/dist/utils/formatting.js.map +1 -0
- package/dist/utils/maybe.d.ts +3 -0
- package/dist/utils/maybe.js +2 -0
- package/dist/utils/maybe.js.map +1 -0
- package/dist/utils/moveElementPreservingAnimation.d.ts +6 -0
- package/dist/utils/moveElementPreservingAnimation.js +61 -0
- package/dist/utils/moveElementPreservingAnimation.js.map +1 -0
- package/dist/utils/nullable.d.ts +12 -0
- package/dist/utils/nullable.js +19 -0
- package/dist/utils/nullable.js.map +1 -0
- package/dist/utils/textCleaning.d.ts +6 -0
- package/dist/utils/textCleaning.js +60 -0
- package/dist/utils/textCleaning.js.map +1 -0
- package/dist/utils/utils.d.ts +33 -0
- package/dist/utils/utils.js +162 -0
- package/dist/utils/utils.js.map +1 -0
- 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
|
+
[](https://www.npmjs.com/package/@daformat/react-number-flow-input)
|
|
4
|
+
[](https://www.npmjs.com/package/@daformat/react-number-flow-input)
|
|
5
|
+
[](https://github.com/daformat)
|
|
6
|
+
[](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>>;
|