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
132 lines
3.3 KiB
TypeScript
132 lines
3.3 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { SPRINGS, prefersReducedMotion, type Spring } from './utils.js';
|
|
|
|
export interface RevealProps {
|
|
children: ReactNode;
|
|
/** Entry direction. Default: 'up' (translateY 8px). */
|
|
from?: 'up' | 'down' | 'left' | 'right' | 'fade' | 'scale';
|
|
/** Distance in pixels for translate variants. Default 8. */
|
|
distance?: number;
|
|
/** Animation duration (ms). Default 320. */
|
|
duration?: number;
|
|
/** Trigger delay (ms). Default 0. */
|
|
delay?: number;
|
|
/** Spring curve preset. Default 'snappy'. */
|
|
spring?: Spring;
|
|
/** IntersectionObserver threshold. Default 0.1. */
|
|
threshold?: number;
|
|
/** Trigger only once (default) vs every time it enters the viewport. */
|
|
once?: boolean;
|
|
/** Bypass animation (e.g. for SSR-stable visual-regression tests). */
|
|
disableMotion?: boolean;
|
|
/** Tailwind escape hatch. */
|
|
className?: string;
|
|
/** Inline style escape hatch — merged AFTER motion vars. */
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<Reveal>` — fades + slides a child into view on viewport entry.
|
|
*
|
|
* Uses IntersectionObserver + CSS transitions; zero runtime physics.
|
|
* Automatically disables animation under `prefers-reduced-motion`.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <Reveal from="up" delay={120}>
|
|
* <h1>Hero headline</h1>
|
|
* </Reveal>
|
|
* ```
|
|
*/
|
|
export function Reveal({
|
|
children,
|
|
from = 'up',
|
|
distance = 8,
|
|
duration = 320,
|
|
delay = 0,
|
|
spring = 'snappy',
|
|
threshold = 0.1,
|
|
once = true,
|
|
disableMotion,
|
|
className,
|
|
style,
|
|
}: RevealProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
const reduced = disableMotion ?? prefersReducedMotion();
|
|
|
|
useEffect(() => {
|
|
if (reduced) {
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
const el = ref.current;
|
|
if (!el || typeof IntersectionObserver === 'undefined') {
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
const io = new IntersectionObserver(
|
|
entries => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
setVisible(true);
|
|
if (once) io.disconnect();
|
|
} else if (!once) {
|
|
setVisible(false);
|
|
}
|
|
}
|
|
},
|
|
{ threshold },
|
|
);
|
|
io.observe(el);
|
|
return () => io.disconnect();
|
|
}, [threshold, once, reduced]);
|
|
|
|
const transform = visible || reduced ? 'none' : initialTransform(from, distance);
|
|
const opacity = visible || reduced ? 1 : from === 'scale' ? 0.92 : 0;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-testid="bl-reveal"
|
|
data-visible={visible || reduced}
|
|
className={className}
|
|
style={{
|
|
transform,
|
|
opacity,
|
|
transition: reduced
|
|
? 'none'
|
|
: `transform ${duration}ms ${SPRINGS[spring]} ${delay}ms, opacity ${duration}ms ${SPRINGS[spring]} ${delay}ms`,
|
|
willChange: 'transform, opacity',
|
|
...style,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function initialTransform(from: RevealProps['from'], d: number): string {
|
|
switch (from) {
|
|
case 'up':
|
|
return `translate3d(0, ${d}px, 0)`;
|
|
case 'down':
|
|
return `translate3d(0, ${-d}px, 0)`;
|
|
case 'left':
|
|
return `translate3d(${d}px, 0, 0)`;
|
|
case 'right':
|
|
return `translate3d(${-d}px, 0, 0)`;
|
|
case 'scale':
|
|
return 'scale(0.96)';
|
|
case 'fade':
|
|
default:
|
|
return 'none';
|
|
}
|
|
}
|