@carissa-io/components-counter 0.0.0-stage → 0.1.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/README.md +80 -2
- package/dist/index.js +177 -0
- package/package.json +57 -4
- package/scss/_counter.scss +22 -0
- package/scss/_variables.scss +11 -0
- package/scss/index.scss +2 -0
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @carissa-io/components-counter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A number that counts up to its value and ticks to a new one when the value changes. A React component, SCSS-themed, safe on
|
|
4
|
+
the server. JavaScript drives the count (`requestAnimationFrame`); it stops while the tab is hidden, and with
|
|
5
|
+
`trigger="view"` while the element is off-screen. It builds on `@carissa-io/components-animate` for the shared hooks
|
|
6
|
+
(`use_reduced_motion`, `use_in_view`).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i @carissa-io/components-counter
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```jsx
|
|
15
|
+
import { Counter } from '@carissa-io/components-counter'
|
|
16
|
+
|
|
17
|
+
<Counter value={12840} locale="en-US" />
|
|
18
|
+
|
|
19
|
+
// Starts when the reader scrolls to it
|
|
20
|
+
<Counter value={99.5} decimals={1} suffix="%" trigger="view" duration={1800} on_complete={() => track('stat_seen')} />
|
|
21
|
+
|
|
22
|
+
// Intl options or a function
|
|
23
|
+
<Counter value={2400} locale="en-US" format={{ style: 'currency', currency: 'USD' }} />
|
|
24
|
+
<Counter value={3} format={(n) => `${Math.round(n)} of 10`} />
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Change `value` later and it ticks from the number on screen to the new one.
|
|
28
|
+
|
|
29
|
+
| Prop | |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `value` | the number to reach (default `0`). NaN, Infinity and anything that is not a number fall back to `0` |
|
|
32
|
+
| `from` | where the first count starts (default `0`; same fallback) |
|
|
33
|
+
| `duration` | milliseconds of one count. Without it the SCSS variable is used (`1.2s`) |
|
|
34
|
+
| `delay` | milliseconds before the first frame (default `0`) |
|
|
35
|
+
| `decimals` | digits after the point, integer 0 to 20 (default `0`). With `format` options, they own the digits unless `decimals` is given |
|
|
36
|
+
| `locale` | BCP 47 tag, the visitor's by default; an invalid one falls back to it |
|
|
37
|
+
| `format` | `Intl.NumberFormat` options (`{ style: 'percent' }`, ...) or a function `(value) => string`; invalid ones fall back to the plain number |
|
|
38
|
+
| `prefix`, `suffix` | text around the number (strings or numbers) |
|
|
39
|
+
| `trigger` | `load` (default, counts as soon as it mounts) or `view` (waits for the viewport, pauses off-screen); anything else falls back to `load` |
|
|
40
|
+
| `on_complete` | `(value) => void`, called each time a count reaches its value |
|
|
41
|
+
| `as` | the tag of the root, `span` by default |
|
|
42
|
+
| `className`, `id`, ... | `className` is appended; unknown props go to the root |
|
|
43
|
+
|
|
44
|
+
The component has no strings of its own: the digits are formatted from `value` and the app supplies any `prefix` or `suffix`.
|
|
45
|
+
|
|
46
|
+
The root carries `data-state` (`counting` or `done`) and `data-motion="reduced"` under reduced motion.
|
|
47
|
+
|
|
48
|
+
## Accessibility
|
|
49
|
+
|
|
50
|
+
The final formatted value (prefix and suffix included) is the container's accessible text from the first render, in a
|
|
51
|
+
visually hidden `c-counter__text` element. The animated digits (`c-counter__value`) are `aria-hidden`, so a screen reader
|
|
52
|
+
never reads the frames and there is no live region. Do not put `aria-live` on it.
|
|
53
|
+
|
|
54
|
+
## Reduced motion, hidden tab, server
|
|
55
|
+
|
|
56
|
+
- `prefers-reduced-motion: reduce`: the final value at once, no frame is scheduled (it follows changes of the setting).
|
|
57
|
+
- The count pauses while the tab is hidden and resumes where it stopped; with `trigger="view"` it also pauses off-screen.
|
|
58
|
+
- Server rendering and the first paint show the final value, so nothing is wrong without JavaScript.
|
|
59
|
+
|
|
60
|
+
## What the app does
|
|
61
|
+
|
|
62
|
+
- Use it for one number worth noticing (one orchestrated moment per page), not for every figure.
|
|
63
|
+
- It passes `locale` (or relies on the visitor's) and any currency; the component has no text of its own.
|
|
64
|
+
|
|
65
|
+
## Style
|
|
66
|
+
|
|
67
|
+
```scss
|
|
68
|
+
@use '@carissa-io/components-counter/scss' with (
|
|
69
|
+
$c-counter-duration: 2s,
|
|
70
|
+
$c-counter-easing: in-out
|
|
71
|
+
);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Classes: `c-counter`, `c-counter__text` (visually hidden final value) and `c-counter__value` (the animated digits).
|
|
75
|
+
JavaScript reads the duration and the easing from the `--c-counter-duration` and `--c-counter-easing` custom properties.
|
|
76
|
+
|
|
77
|
+
| Variable | Default |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `$c-counter-duration` | `1.2s` (a CSS time; the `duration` prop of one instance wins) |
|
|
80
|
+
| `$c-counter-easing` | `out` (`out`, `in-out` or `linear`) |
|
|
81
|
+
| `$c-counter-font-variant-numeric` | `tabular-nums` |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/Counter.jsx
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { use_in_view, use_reduced_motion } from "@carissa-io/components-animate";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
var TRIGGERS = ["load", "view"];
|
|
6
|
+
var TAG = /^[a-z][a-z0-9-]*$/i;
|
|
7
|
+
var DEFAULT_DURATION = 1200;
|
|
8
|
+
var EASINGS = {
|
|
9
|
+
out: (p) => 1 - (1 - p) ** 3,
|
|
10
|
+
"in-out": (p) => p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2,
|
|
11
|
+
linear: (p) => p
|
|
12
|
+
};
|
|
13
|
+
var join = (...parts) => parts.filter(Boolean).join(" ");
|
|
14
|
+
var to_number = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
15
|
+
var to_ms = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
16
|
+
var to_text = (value) => typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
17
|
+
function css_time(raw) {
|
|
18
|
+
const match = /^\s*(\d+(?:\.\d+)?)(ms|s)\s*$/.exec(raw ?? "");
|
|
19
|
+
if (!match) return void 0;
|
|
20
|
+
return match[2] === "s" ? Number(match[1]) * 1e3 : Number(match[1]);
|
|
21
|
+
}
|
|
22
|
+
var noop_subscribe = () => () => {
|
|
23
|
+
};
|
|
24
|
+
var on_client = () => true;
|
|
25
|
+
var on_server = () => false;
|
|
26
|
+
function subscribe_visibility(on_change) {
|
|
27
|
+
document.addEventListener("visibilitychange", on_change);
|
|
28
|
+
return () => document.removeEventListener("visibilitychange", on_change);
|
|
29
|
+
}
|
|
30
|
+
var tab_hidden = () => document.visibilityState === "hidden";
|
|
31
|
+
function make_formatter(locale, decimals, options_key) {
|
|
32
|
+
const options = options_key ? JSON.parse(options_key) : {};
|
|
33
|
+
const fixed = decimals === void 0 ? {} : { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
|
|
34
|
+
const plain = { minimumFractionDigits: decimals ?? 0, maximumFractionDigits: decimals ?? 0 };
|
|
35
|
+
const attempts = [
|
|
36
|
+
[locale, { ...options_key ? {} : plain, ...fixed, ...options }],
|
|
37
|
+
[locale, plain],
|
|
38
|
+
[void 0, plain]
|
|
39
|
+
];
|
|
40
|
+
for (const [loc, opts] of attempts) {
|
|
41
|
+
try {
|
|
42
|
+
return new Intl.NumberFormat(loc, opts);
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return new Intl.NumberFormat();
|
|
47
|
+
}
|
|
48
|
+
function Counter({
|
|
49
|
+
value = 0,
|
|
50
|
+
from = 0,
|
|
51
|
+
duration,
|
|
52
|
+
delay = 0,
|
|
53
|
+
decimals,
|
|
54
|
+
locale,
|
|
55
|
+
format,
|
|
56
|
+
prefix,
|
|
57
|
+
suffix,
|
|
58
|
+
trigger = "load",
|
|
59
|
+
on_complete,
|
|
60
|
+
as = "span",
|
|
61
|
+
className = "",
|
|
62
|
+
...rest
|
|
63
|
+
}) {
|
|
64
|
+
const target = to_number(value, 0);
|
|
65
|
+
const start = to_number(from, 0);
|
|
66
|
+
const safe_trigger = TRIGGERS.includes(trigger) ? trigger : "load";
|
|
67
|
+
const Tag = typeof as === "string" && TAG.test(as) ? as : "span";
|
|
68
|
+
const digits = Number.isInteger(decimals) && decimals >= 0 && decimals <= 20 ? decimals : void 0;
|
|
69
|
+
const safe_locale = typeof locale === "string" && locale ? locale : void 0;
|
|
70
|
+
const format_fn = typeof format === "function" ? format : null;
|
|
71
|
+
let format_key = "";
|
|
72
|
+
if (!format_fn && format && typeof format === "object") {
|
|
73
|
+
try {
|
|
74
|
+
format_key = JSON.stringify(format);
|
|
75
|
+
} catch {
|
|
76
|
+
format_key = "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const safe_duration = to_ms(duration, void 0);
|
|
80
|
+
const safe_delay = to_ms(delay, 0);
|
|
81
|
+
const reduced = use_reduced_motion();
|
|
82
|
+
const [set_view_node, in_view] = use_in_view();
|
|
83
|
+
const hidden = useSyncExternalStore(subscribe_visibility, tab_hidden, () => false);
|
|
84
|
+
const mounted = useSyncExternalStore(noop_subscribe, on_client, on_server);
|
|
85
|
+
const node_ref = useRef(null);
|
|
86
|
+
const ref = useCallback(
|
|
87
|
+
(node) => {
|
|
88
|
+
node_ref.current = node;
|
|
89
|
+
set_view_node(node);
|
|
90
|
+
},
|
|
91
|
+
[set_view_node]
|
|
92
|
+
);
|
|
93
|
+
const complete_ref = useRef(on_complete);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
complete_ref.current = on_complete;
|
|
96
|
+
});
|
|
97
|
+
const [shown, set_shown] = useState(null);
|
|
98
|
+
const current = useRef(null);
|
|
99
|
+
const run = useRef(null);
|
|
100
|
+
const can_run = !hidden && (safe_trigger === "load" || in_view);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (reduced || !can_run) return void 0;
|
|
103
|
+
const cur = current.current ?? start;
|
|
104
|
+
if (!run.current || run.current.to !== target) run.current = { to: target, origin: cur };
|
|
105
|
+
if (cur === target) return void 0;
|
|
106
|
+
const { origin } = run.current;
|
|
107
|
+
const node = node_ref.current;
|
|
108
|
+
const style = node ? getComputedStyle(node) : null;
|
|
109
|
+
const total = safe_duration ?? css_time(style?.getPropertyValue("--c-counter-duration")) ?? DEFAULT_DURATION;
|
|
110
|
+
const easing_name = style?.getPropertyValue("--c-counter-easing").trim();
|
|
111
|
+
const ease = EASINGS[easing_name] ?? EASINGS.out;
|
|
112
|
+
const span = Math.abs(target - origin);
|
|
113
|
+
const length = span === 0 ? 0 : total * (Math.abs(target - cur) / span);
|
|
114
|
+
const wait = cur === origin ? safe_delay : 0;
|
|
115
|
+
let frame = 0;
|
|
116
|
+
let timer = 0;
|
|
117
|
+
let t0 = null;
|
|
118
|
+
const tick = (now) => {
|
|
119
|
+
if (t0 === null) t0 = now;
|
|
120
|
+
const p = length <= 0 ? 1 : Math.min(1, (now - t0) / length);
|
|
121
|
+
const next = p >= 1 ? target : cur + (target - cur) * ease(p);
|
|
122
|
+
current.current = next;
|
|
123
|
+
set_shown(next);
|
|
124
|
+
if (p < 1) {
|
|
125
|
+
frame = requestAnimationFrame(tick);
|
|
126
|
+
} else {
|
|
127
|
+
run.current = null;
|
|
128
|
+
if (typeof complete_ref.current === "function") complete_ref.current(target);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const begin = () => {
|
|
132
|
+
frame = requestAnimationFrame(tick);
|
|
133
|
+
};
|
|
134
|
+
if (wait > 0) timer = setTimeout(begin, wait);
|
|
135
|
+
else begin();
|
|
136
|
+
return () => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
cancelAnimationFrame(frame);
|
|
139
|
+
};
|
|
140
|
+
}, [reduced, can_run, target, start, safe_duration, safe_delay]);
|
|
141
|
+
const formatter = useMemo(
|
|
142
|
+
() => make_formatter(safe_locale, digits, format_key),
|
|
143
|
+
[safe_locale, digits, format_key]
|
|
144
|
+
);
|
|
145
|
+
const render_number = (n) => {
|
|
146
|
+
if (format_fn) {
|
|
147
|
+
try {
|
|
148
|
+
const out = format_fn(n);
|
|
149
|
+
if (typeof out === "string" || typeof out === "number") return String(out);
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return formatter.format(n);
|
|
154
|
+
};
|
|
155
|
+
const before = to_text(prefix);
|
|
156
|
+
const after = to_text(suffix);
|
|
157
|
+
const final_text = `${before}${render_number(target)}${after}`;
|
|
158
|
+
const on_screen = reduced || !mounted ? target : shown ?? start;
|
|
159
|
+
const visible_text = on_screen === target ? final_text : `${before}${render_number(on_screen)}${after}`;
|
|
160
|
+
return /* @__PURE__ */ jsxs(
|
|
161
|
+
Tag,
|
|
162
|
+
{
|
|
163
|
+
...rest,
|
|
164
|
+
ref,
|
|
165
|
+
"data-state": reduced || !mounted || on_screen === target ? "done" : "counting",
|
|
166
|
+
"data-motion": reduced ? "reduced" : void 0,
|
|
167
|
+
className: join("c-counter", className),
|
|
168
|
+
children: [
|
|
169
|
+
/* @__PURE__ */ jsx("span", { className: "c-counter__text", children: final_text }),
|
|
170
|
+
/* @__PURE__ */ jsx("span", { className: "c-counter__value", "aria-hidden": "true", children: visible_text })
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
export {
|
|
176
|
+
Counter
|
|
177
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carissa-io/components-counter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Carissa UI: counter as a React component with SCSS variables",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"scss",
|
|
10
|
+
"THIRD-PARTY-NOTICES"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/index.js",
|
|
14
|
+
"./scss": "./scss/index.scss",
|
|
15
|
+
"./scss/*": "./scss/*"
|
|
16
|
+
},
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "esbuild src/index.js --bundle --packages=external --format=esm --jsx=automatic --outfile=dist/index.js",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"lint": "eslint .",
|
|
22
|
+
"prepublishOnly": "node -e \"if (!process.env.GITHUB_ACTIONS) { console.error('Refusing to publish outside GitHub Actions CI: commit and push to main instead.'); process.exit(1); }\" && npm run build"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/carissa-io/components-counter.git"
|
|
27
|
+
},
|
|
28
|
+
"license": "UNLICENSED",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@carissa-io/components-animate": "^0.1.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^10.0.1",
|
|
40
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
41
|
+
"@testing-library/react": "^16.3.3",
|
|
42
|
+
"@testing-library/user-event": "^14.6.7",
|
|
43
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
44
|
+
"esbuild": "^0.28.0",
|
|
45
|
+
"eslint": "^10.12.0",
|
|
46
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
47
|
+
"globals": "^17.13.0",
|
|
48
|
+
"jsdom": "^29.1.1",
|
|
49
|
+
"react": "^19.3.0",
|
|
50
|
+
"react-dom": "^19.3.0",
|
|
51
|
+
"sass-embedded": "^1.105.1",
|
|
52
|
+
"vite": "^8.3.2",
|
|
53
|
+
"vitest": "^4.1.11"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"registry": "https://registry.npmjs.org",
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
@use 'variables' as *;
|
|
2
|
+
|
|
3
|
+
.c-counter {
|
|
4
|
+
--c-counter-duration: #{$c-counter-duration};
|
|
5
|
+
--c-counter-easing: #{$c-counter-easing};
|
|
6
|
+
|
|
7
|
+
position: relative;
|
|
8
|
+
font-variant-numeric: $c-counter-font-variant-numeric;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The final value, visually hidden and still read by assistive technology (the animated digits are aria-hidden).
|
|
12
|
+
.c-counter__text {
|
|
13
|
+
position: absolute;
|
|
14
|
+
width: 1px;
|
|
15
|
+
height: 1px;
|
|
16
|
+
padding: 0;
|
|
17
|
+
margin: -1px;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
clip-path: inset(50%);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
border: 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Every value is `!default`: set it before the first @use / through `with (...)` and it wins.
|
|
2
|
+
//
|
|
3
|
+
// @use '@carissa-io/components-counter/scss' with ($c-counter-duration: 2s, $c-counter-easing: in-out);
|
|
4
|
+
//
|
|
5
|
+
// The count is driven by JavaScript, which reads the duration and the easing from custom properties on the element, so a
|
|
6
|
+
// theme sets the character of the motion here. The `duration` prop of one instance wins over the duration.
|
|
7
|
+
// Under `prefers-reduced-motion: reduce` the component shows the final value at once.
|
|
8
|
+
|
|
9
|
+
$c-counter-duration: 1.2s !default; // a CSS time (`1.2s`, `800ms`)
|
|
10
|
+
$c-counter-easing: out !default; // `out` (decelerating), `in-out` or `linear`
|
|
11
|
+
$c-counter-font-variant-numeric: tabular-nums !default; // equal-width digits, so the number does not jitter
|
package/scss/index.scss
ADDED