learning_ai_common_plat/packages/motion/src/Reveal.tsx
saravanakumardb1 d082480849 feat(packages): Wave 4 motion + Wave 5b data-viz + Wave 7 notifications-ui
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
2026-05-27 13:08:30 -07:00

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';
}
}