diff --git a/web/package.json b/web/package.json index feceac6..b1e83d3 100644 --- a/web/package.json +++ b/web/package.json @@ -55,7 +55,7 @@ "lowlight": "^3.3.0", "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", - "motion": "^12.6.5", + "motion": "^12.7.4", "nanoid": "^5.1.5", "next": "^15.2.3", "next-themes": "^0.4.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a8d9798..e9878c4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -123,7 +123,7 @@ importers: specifier: ^0.487.0 version: 0.487.0(react@19.1.0) motion: - specifier: ^12.6.5 + specifier: ^12.7.4 version: 12.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nanoid: specifier: ^5.1.5 diff --git a/web/src/app/chat/components/site-header.tsx b/web/src/app/chat/components/site-header.tsx index abfd123..311a326 100644 --- a/web/src/app/chat/components/site-header.tsx +++ b/web/src/app/chat/components/site-header.tsx @@ -4,6 +4,7 @@ import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; import Link from "next/link"; +import { NumberTicker } from "~/components/magicui/number-ticker"; import { Button } from "~/components/ui/button"; import { env } from "~/env"; @@ -72,9 +73,9 @@ export async function StarCounter() { return ( <> - - {stars !== null ? stars.toLocaleString() : "—"} - + {stars && ( + + )} ); } diff --git a/web/src/components/magicui/number-ticker.tsx b/web/src/components/magicui/number-ticker.tsx new file mode 100644 index 0000000..f041565 --- /dev/null +++ b/web/src/components/magicui/number-ticker.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useInView, useMotionValue, useSpring } from "motion/react"; +import { ComponentPropsWithoutRef, useEffect, useRef } from "react"; + +import { cn } from "~/lib/utils"; + +interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> { + value: number; + startValue?: number; + direction?: "up" | "down"; + delay?: number; + decimalPlaces?: number; +} + +export function NumberTicker({ + value, + startValue = 0, + direction = "up", + delay = 0, + className, + decimalPlaces = 0, + ...props +}: NumberTickerProps) { + const ref = useRef(null); + const motionValue = useMotionValue(direction === "down" ? value : startValue); + const springValue = useSpring(motionValue, { + damping: 60, + stiffness: 100, + }); + const isInView = useInView(ref, { once: true, margin: "0px" }); + + useEffect(() => { + if (isInView) { + const timer = setTimeout(() => { + motionValue.set(direction === "down" ? startValue : value); + }, delay * 1000); + return () => clearTimeout(timer); + } + }, [motionValue, isInView, delay, value, direction, startValue]); + + useEffect( + () => + springValue.on("change", (latest) => { + if (ref.current) { + ref.current.textContent = Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(Number(latest.toFixed(decimalPlaces))); + } + }), + [springValue, decimalPlaces], + ); + + return ( + + {startValue} + + ); +}