Three new product-agnostic packages unlock visible elegance lifts
across every product:
═══════════════════════════════════════════════════════════════════════
@bytelyst/motion@0.1.0 — Wave 4 elegance primitives (2.21 KB / 8 KB)
═══════════════════════════════════════════════════════════════════════
<Reveal> — IntersectionObserver-based fade/slide entry,
6 directions, configurable spring + delay
<StaggerList> — sequenced reveal of children with per-item delay
<NumberFlow> — RAF-tweened number counter, cubic-out easing,
Intl-formatted, prefers-reduced-motion aware
<TiltCard> — 3D perspective tilt + cursor-tracking glare
overlay (single-element ref, no React rerenders)
<ScrollProgress> — fixed scroll-position bar (window or any element)
Plus exported `SPRINGS` (4 cubic-bezier presets) + `prefersReducedMotion`
helper. Every primitive accepts `disableMotion` for snapshot tests.
═══════════════════════════════════════════════════════════════════════
@bytelyst/data-viz@0.1.0 — Wave 5b viz primitives (2.63 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════
<Sparkline> — line trend with gradient fill + last-point marker
<BarSparkline> — discrete-bar mini-chart with max-bar highlight
<KpiCard> — label + headline + delta arrow + sparkline; supports
'goodWhen=lower' for latency/cost metrics
<ProgressRing> — circular progress with center content slot,
animated stroke-dashoffset
<Heatmap> — GitHub-style calendar grid with color-mix() intensity
All pure SVG / CSS — zero runtime dependencies.
═══════════════════════════════════════════════════════════════════════
@bytelyst/notifications-ui@0.1.0 — Wave 7 essentials (3.31 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════
<NotificationCenter> — bell trigger + badge + dropdown panel with
All / Unread / Mentions tabs, outside-click
+ Escape close, mark-all-read action
<InboxItem> — single row with unread dot, kind glyph,
relative timestamp, optional action buttons
<BannerStack> — top-of-page strip with maxVisible + +N more,
accent-bordered tone variants, dismissible
<Announcement> — inline 'What's new' pill (3 tone variants)
5 notification kinds (info/success/warning/danger/mention) + 5 banner
kinds (... + announcement gradient).
═══════════════════════════════════════════════════════════════════════
Quality gates
═══════════════════════════════════════════════════════════════════════
All three packages: tsc --noEmit clean, build clean.
Tests: motion 16/16 · data-viz 14/14 · notifications-ui 17/17
Bundles: motion 2.21 KB · data-viz 2.63 KB · noti-ui 3.31 KB
Budgets: added to .size-limit.cjs (8/10/10 KB respectively)
Refs:
learning_ai_uxui_web/docs/ROADMAP_2026.md
§Wave 4 (Motion), §Wave 5b (Charts), §Wave 7 (Productisation)
Decisions doc §13 (mobile-native = tokens-only) leaves room for these
web-first packages to be the canonical surface
106 lines
2.7 KiB
TypeScript
106 lines
2.7 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
} from 'react';
|
|
import { SPRINGS, prefersReducedMotion } from './utils.js';
|
|
|
|
export interface NumberFlowProps {
|
|
/** Target value to animate to. */
|
|
value: number;
|
|
/** Tween duration in ms. Default 700. */
|
|
duration?: number;
|
|
/** Decimal places. Default 0. */
|
|
decimals?: number;
|
|
/** Optional formatter (overrides `decimals` + locale). */
|
|
format?: (n: number) => string;
|
|
/** Locale for default formatter. Default 'en-US'. */
|
|
locale?: string;
|
|
/** Prefix rendered before the number (e.g. '$'). */
|
|
prefix?: string;
|
|
/** Suffix rendered after the number (e.g. '%'). */
|
|
suffix?: string;
|
|
/** Bypass animation. */
|
|
disableMotion?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<NumberFlow>` — smoothly tweens a number from its previous value to
|
|
* the new one. Counts up, counts down, and respects
|
|
* `prefers-reduced-motion` (snaps to final value).
|
|
*
|
|
* Pure RAF — no dependencies. Cancels in-flight tweens when `value`
|
|
* changes mid-animation so the latest target always wins.
|
|
*/
|
|
export function NumberFlow({
|
|
value,
|
|
duration = 700,
|
|
decimals = 0,
|
|
format,
|
|
locale = 'en-US',
|
|
prefix,
|
|
suffix,
|
|
disableMotion,
|
|
className,
|
|
style,
|
|
}: NumberFlowProps) {
|
|
const [display, setDisplay] = useState(value);
|
|
const fromRef = useRef(value);
|
|
const rafRef = useRef<number | null>(null);
|
|
const reduced = disableMotion ?? prefersReducedMotion();
|
|
|
|
useEffect(() => {
|
|
if (reduced) {
|
|
fromRef.current = value;
|
|
setDisplay(value);
|
|
return;
|
|
}
|
|
const from = fromRef.current;
|
|
const start = performance.now();
|
|
const tween = (now: number) => {
|
|
const t = Math.min(1, (now - start) / duration);
|
|
// Apply a snappy ease-out so the number doesn't feel linear.
|
|
const eased = 1 - Math.pow(1 - t, 3);
|
|
const current = from + (value - from) * eased;
|
|
setDisplay(current);
|
|
if (t < 1) {
|
|
rafRef.current = requestAnimationFrame(tween);
|
|
} else {
|
|
fromRef.current = value;
|
|
rafRef.current = null;
|
|
}
|
|
};
|
|
rafRef.current = requestAnimationFrame(tween);
|
|
return () => {
|
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
};
|
|
}, [value, duration, reduced]);
|
|
|
|
const formatted = format
|
|
? format(display)
|
|
: new Intl.NumberFormat(locale, {
|
|
minimumFractionDigits: decimals,
|
|
maximumFractionDigits: decimals,
|
|
}).format(display);
|
|
|
|
return (
|
|
<span
|
|
data-testid="bl-number-flow"
|
|
data-target={value}
|
|
className={className}
|
|
style={{
|
|
fontVariantNumeric: 'tabular-nums',
|
|
transition: `color 200ms ${SPRINGS.snappy}`,
|
|
...style,
|
|
}}
|
|
>
|
|
{prefix}
|
|
{formatted}
|
|
{suffix}
|
|
</span>
|
|
);
|
|
}
|