animated-number.tsx 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import { For, Index, createEffect, createMemo, on } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
  4. const DURATION = 600
  5. function normalize(value: number) {
  6. return ((value % 10) + 10) % 10
  7. }
  8. function spin(from: number, to: number, direction: 1 | -1) {
  9. if (from === to) return 0
  10. if (direction > 0) return (to - from + 10) % 10
  11. return -((from - to + 10) % 10)
  12. }
  13. function Digit(props: { value: number; direction: 1 | -1 }) {
  14. const [state, setState] = createStore({
  15. step: props.value + 10,
  16. animating: false,
  17. })
  18. const step = () => state.step
  19. const animating = () => state.animating
  20. let last = props.value
  21. createEffect(
  22. on(
  23. () => props.value,
  24. (next) => {
  25. const delta = spin(last, next, props.direction)
  26. last = next
  27. if (!delta) {
  28. setState("animating", false)
  29. setState("step", next + 10)
  30. return
  31. }
  32. setState("animating", true)
  33. setState("step", (value) => value + delta)
  34. },
  35. { defer: true },
  36. ),
  37. )
  38. return (
  39. <span data-slot="animated-number-digit">
  40. <span
  41. data-slot="animated-number-strip"
  42. data-animating={animating() ? "true" : "false"}
  43. onTransitionEnd={() => {
  44. setState("animating", false)
  45. setState("step", (value) => normalize(value) + 10)
  46. }}
  47. style={{
  48. "--animated-number-offset": `${step()}`,
  49. "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
  50. }}
  51. >
  52. <For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
  53. </span>
  54. </span>
  55. )
  56. }
  57. export function AnimatedNumber(props: { value: number; class?: string }) {
  58. const target = createMemo(() => {
  59. if (!Number.isFinite(props.value)) return 0
  60. return Math.max(0, Math.round(props.value))
  61. })
  62. const [state, setState] = createStore({
  63. value: target(),
  64. direction: 1 as 1 | -1,
  65. })
  66. const value = () => state.value
  67. const direction = () => state.direction
  68. createEffect(
  69. on(
  70. target,
  71. (next) => {
  72. const current = value()
  73. if (next === current) return
  74. setState("direction", next > current ? 1 : -1)
  75. setState("value", next)
  76. },
  77. { defer: true },
  78. ),
  79. )
  80. const label = createMemo(() => value().toString())
  81. const digits = createMemo(() =>
  82. Array.from(label(), (char) => {
  83. const code = char.charCodeAt(0) - 48
  84. if (code < 0 || code > 9) return 0
  85. return code
  86. }).reverse(),
  87. )
  88. const width = createMemo(() => `${digits().length}ch`)
  89. return (
  90. <span data-component="animated-number" class={props.class} aria-label={label()}>
  91. <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
  92. <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
  93. </span>
  94. </span>
  95. )
  96. }