Token Counter

Previous

Accurately counts and tracks tokens in real time to optimize input limits

Docs
paceuiui

Preview

Loading preview…
ai/token-counter.tsx
"use client";

import { ComponentProps, RefObject, useRef, useState } from "react";
import { ClassNameValue } from "tailwind-merge";

import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";

import { cn } from "@/lib/utils";

gsap.registerPlugin(MotionPathPlugin);

type TokenCounterProps = ComponentProps<"p"> & {
    sourceRef: RefObject<HTMLElement | null>;
    token: number;
    flyingClassName?: ClassNameValue;
};

export const TokenCounter = ({ sourceRef, token, className, flyingClassName, ...props }: TokenCounterProps) => {
    const [currentCount, setCurrentCount] = useState(token);
    const counterRef = useRef<HTMLParagraphElement>(null);
    const flyingTokenRef = useRef<HTMLParagraphElement>(null);
    const animationTimeline = useRef<gsap.core.Timeline | null>(null);

    useGSAP(
        () => {
            const tokenDifference = token - currentCount;
            const counterElement = counterRef.current;
            const sourceElement = sourceRef.current;
            const flyingElement = flyingTokenRef.current;

            if (!counterElement || !sourceElement || !flyingElement) return;

            flyingElement.innerText = `${tokenDifference > 0 ? "+" : ""}${tokenDifference}`;

            const buttonRect = sourceElement.getBoundingClientRect();
            const counterRect = counterElement.getBoundingClientRect();
            const flyingRect = flyingElement.getBoundingClientRect();

            const startPosition = {
                x: buttonRect.left + buttonRect.width / 2 - flyingRect.width / 2,
                y: buttonRect.top,
            };
            const endPosition = {
                x: counterRect.left + counterRect.width / 2 - flyingRect.width / 2,
                y: counterRect.top,
            };
            const midPosition = {
                x: (startPosition.x + endPosition.x) / 2,
                y: Math.min(startPosition.y, endPosition.y) - 20,
            };

            gsap.set(flyingElement, {
                x: startPosition.x,
                y: startPosition.y,
                scale: 0,
                opacity: 0,
                clipPath: "circle(0% at 50% 50%)",
            });

            animationTimeline.current?.kill();

            const obj = { value: currentCount };
            const targetValue = token;

            const tl = gsap.timeline();
            animationTimeline.current = tl;

            tl.to(
                flyingElement,
                {
                    duration: 1.5,
                    ease: "power2.inOut",
                    motionPath: {
                        path: [startPosition, midPosition, endPosition],
                        autoRotate: false,
                    },
                },
                0,
            );

            tl.to(
                flyingElement,
                {
                    opacity: 1,
                    scale: 1,
                    clipPath: "circle(100% at 50% 50%)",
                    duration: 0.5,
                    ease: "power1.out",
                },
                0,
            );

            tl.to(
                flyingElement,
                {
                    opacity: 0,
                    scale: 0.5,
                    clipPath: "circle(0% at 50% 50%)",
                    duration: 0.3,
                    ease: "power1.in",
                },
                1.0,
            );

            tl.to(
                obj,
                {
                    value: targetValue,
                    duration: 1,
                    ease: "power1.out",
                    onUpdate: () => setCurrentCount(Math.floor(obj.value)),
                    onComplete: () => setCurrentCount(token),
                },
                1,
            );
        },
        { dependencies: [token] },
    );

    return (
        <>
            <p {...props} ref={counterRef} className={className}>
                {currentCount}
            </p>

            <p
                ref={flyingTokenRef}
                className={cn("pointer-events-none fixed", flyingClassName)}
                style={{
                    top: 0,
                    left: 0,
                    willChange: "transform, opacity, clip-path",
                }}>
                +0
            </p>
        </>
    );
};

Installation

npx shadcn@latest add @paceui/token-counter

Usage

import { TokenCounter } from "@/components/ui/token-counter"
<TokenCounter />
Token Counter - shadcn index