- Hero auto-rotates story backdrops every 5 seconds with 500ms fade transitions - ProductShowcase cards rotate with mint ring highlight on the active card - Inactive cards fade to 60% opacity on mobile, full opacity on desktop - New components: StoryBackdrop, StoryOnboarding, CountUp, lib/stories - Manual Another-story control still works Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
52 lines
1.3 KiB
TypeScript
52 lines
1.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
/** Animates from 0 to `value` when scrolled into view. */
|
|
export function CountUp({
|
|
value,
|
|
prefix = "",
|
|
suffix = "",
|
|
decimals = 0,
|
|
duration = 1400,
|
|
}: {
|
|
value: number;
|
|
prefix?: string;
|
|
suffix?: string;
|
|
decimals?: number;
|
|
duration?: number;
|
|
}) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const [display, setDisplay] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (!entries[0].isIntersecting) return;
|
|
io.disconnect();
|
|
const start = performance.now();
|
|
const tick = (now: number) => {
|
|
const p = Math.min((now - start) / duration, 1);
|
|
const eased = 1 - Math.pow(1 - p, 3); // ease-out cubic
|
|
setDisplay(value * eased);
|
|
if (p < 1) requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
},
|
|
{ threshold: 0.4 }
|
|
);
|
|
io.observe(el);
|
|
return () => io.disconnect();
|
|
}, [value, duration]);
|
|
|
|
return (
|
|
<span ref={ref}>
|
|
{prefix}
|
|
{display.toLocaleString("en-US", { maximumFractionDigits: decimals, minimumFractionDigits: decimals })}
|
|
{suffix}
|
|
</span>
|
|
);
|
|
}
|