That is correct for marketing pages. It is wrong for dashboards nobody scrolls — wall-mounted / always-on displays, or just a short browser window.
Two concrete failures:
AnimatedNumber sticks at 0. The count-up only runs inside if (inView) mv.set(value). On a short viewport the negative margin shrinks the detection band enough that a stat tile never qualifies, so it renders a permanent 0 while the real value sits in the sr-only span. A stat tile silently showing the wrong number is worse than no animation.
FadeIn leaves content invisible. Primary content below the fold stays at opacity: 0 indefinitely.
Reproduce
Render <StatCard label="On time" animate={4} /> (or any FadeIn) so it sits below roughly 80% of viewport height, at a viewport around 800x460, and don't scroll. The tile shows 0.
Hit while adopting the kit in jason/unifi-access-dashboard — an attendance board that runs full-screen on a wall display.
Suggested fix
Give both an opt-out of the scroll gate, defaulting to today's behaviour so existing callers are unaffected:
<FadeInimmediate/>// animate on mount
<AnimatedNumbervalue={4}immediate/>// count up on mount
Implementation is small — const active = immediate || inView; in AnimatedNumber, and swapping whileInView for animate in FadeIn when immediate is set.
Alternatively AnimatedNumber could always land on the correct value and treat the animation as the optional part — if not in view, setDisplay(format(value)) rather than leaving 0. That fixes the correctness bug even without a new prop, and is arguably right regardless.
Workaround in the meantime
Keep them above the fold, or use StatCard's static value prop instead of animate.
## Problem
Both motion primitives only activate once scrolled into view:
- `src/motion/index.tsx` — `FadeIn` uses `whileInView` with `viewport={{ once: true, margin: "-10% 0px" }}`
- `src/animated/animated-number.tsx` — `AnimatedNumber` uses `useInView(ref, { once: true, margin: "-20% 0px" })`
That is correct for marketing pages. It is wrong for dashboards nobody scrolls — wall-mounted / always-on displays, or just a short browser window.
Two concrete failures:
1. **`AnimatedNumber` sticks at `0`.** The count-up only runs inside `if (inView) mv.set(value)`. On a short viewport the negative margin shrinks the detection band enough that a stat tile never qualifies, so it renders a permanent **0** while the real value sits in the `sr-only` span. A stat tile silently showing the wrong number is worse than no animation.
2. **`FadeIn` leaves content invisible.** Primary content below the fold stays at `opacity: 0` indefinitely.
## Reproduce
Render `<StatCard label="On time" animate={4} />` (or any `FadeIn`) so it sits below roughly 80% of viewport height, at a viewport around 800x460, and don't scroll. The tile shows `0`.
Hit while adopting the kit in `jason/unifi-access-dashboard` — an attendance board that runs full-screen on a wall display.
## Suggested fix
Give both an opt-out of the scroll gate, defaulting to today's behaviour so existing callers are unaffected:
```tsx
<FadeIn immediate /> // animate on mount
<AnimatedNumber value={4} immediate /> // count up on mount
```
Implementation is small — `const active = immediate || inView;` in `AnimatedNumber`, and swapping `whileInView` for `animate` in `FadeIn` when `immediate` is set.
Alternatively `AnimatedNumber` could always land on the correct value and treat the animation as the optional part — if not in view, `setDisplay(format(value))` rather than leaving `0`. That fixes the correctness bug even without a new prop, and is arguably right regardless.
## Workaround in the meantime
Keep them above the fold, or use `StatCard`'s static `value` prop instead of `animate`.
jason
changed title from scope probe to FadeIn and AnimatedNumber are scroll-gated, so they break always-on dashboards2026-07-30 12:44:02 -05:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
Both motion primitives only activate once scrolled into view:
src/motion/index.tsx—FadeInuseswhileInViewwithviewport={{ once: true, margin: "-10% 0px" }}src/animated/animated-number.tsx—AnimatedNumberusesuseInView(ref, { once: true, margin: "-20% 0px" })That is correct for marketing pages. It is wrong for dashboards nobody scrolls — wall-mounted / always-on displays, or just a short browser window.
Two concrete failures:
AnimatedNumbersticks at0. The count-up only runs insideif (inView) mv.set(value). On a short viewport the negative margin shrinks the detection band enough that a stat tile never qualifies, so it renders a permanent 0 while the real value sits in thesr-onlyspan. A stat tile silently showing the wrong number is worse than no animation.FadeInleaves content invisible. Primary content below the fold stays atopacity: 0indefinitely.Reproduce
Render
<StatCard label="On time" animate={4} />(or anyFadeIn) so it sits below roughly 80% of viewport height, at a viewport around 800x460, and don't scroll. The tile shows0.Hit while adopting the kit in
jason/unifi-access-dashboard— an attendance board that runs full-screen on a wall display.Suggested fix
Give both an opt-out of the scroll gate, defaulting to today's behaviour so existing callers are unaffected:
Implementation is small —
const active = immediate || inView;inAnimatedNumber, and swappingwhileInViewforanimateinFadeInwhenimmediateis set.Alternatively
AnimatedNumbercould always land on the correct value and treat the animation as the optional part — if not in view,setDisplay(format(value))rather than leaving0. That fixes the correctness bug even without a new prop, and is arguably right regardless.Workaround in the meantime
Keep them above the fold, or use
StatCard's staticvalueprop instead ofanimate.scope probeto FadeIn and AnimatedNumber are scroll-gated, so they break always-on dashboards