Docs

Fund Widget

This component is a financial dashboard-like UI element for showing the current status of the funds.

Installation

Install dependencies

npm install framer-motion

Run the following command

It will create a new file fund-widget.tsx inside the components/animata/widget directory.

mkdir -p components/animata/widget && touch components/animata/widget/fund-widget.tsx

Paste the code

Open the newly created file and paste the following code:

import { useEffect, useState } from "react";
import { AnimatePresence, motion, PanInfo } from "framer-motion";
 
import { cn } from "@/lib/utils";
 
type Fund = {
  value: string;
  change: number;
  label: string;
};
 
interface FundWidgetProps {
  /**
   * The array which contains all the funds with their value, changes, and label.
   */
  funds: Fund[];
 
  /**
   * Class name for the background element.
   */
  backgroundClassName?: string;
 
  /**
   * Class name for the container element.
   */
  containerClassName?: string;
}
 
export default function FundWidget({
  funds = [
    { value: "2.7Cr", change: 12, label: "Stocks" },
    { value: "3.5Cr", change: -8, label: "Funds" },
    { value: "1.2Cr", change: 6, label: "Deposits" },
  ],
  backgroundClassName,
  containerClassName,
}: FundWidgetProps) {
  const len = funds.length;
 
  const [[activeDiv, direction], setDirection] = useState([0, 0]);
  const [dragDistance, setDragDistance] = useState(0);
 
  // Reset dragDistance after the drag ends to remove blur/rotate effects
  useEffect(() => {
    if (dragDistance !== 0) {
      const timer = setTimeout(() => {
        setDragDistance(0);
      }, 500);
      return () => clearTimeout(timer);
    }
  }, [activeDiv, dragDistance]);
 
  const sliderVariants = {
    incoming: (direction: number) => ({
      y: direction > 0 ? "100%" : "-100%",
      scale: 1.0,
      opacity: 0,
    }),
    active: { y: 0, scale: 1, opacity: 1 },
    exit: (direction: number) => ({
      y: direction > 0 ? "100%" : "-100%",
      scale: 1,
      opacity: 0.2,
    }),
  };
 
  const sliderTransition = {
    duration: 0.5,
    ease: [0.56, 0.03, 0.12, 1.04],
  };
 
  const swipeToAction = (direction: number) => {
    const newDiv = activeDiv + direction;
    if (newDiv < 0 || newDiv >= len) return;
 
    setDirection([newDiv, direction]);
  };
 
  const draghandler = (dragInfo: PanInfo) => {
    const dragDistanceY = dragInfo.offset.y;
    const swipeThreshold = 20;
 
    // Only swipe down if not at the first div (activeDiv !== 0)
    if (dragDistanceY > swipeThreshold) {
      swipeToAction(-1);
    }
    // Only swipe up if not at the last div (activeDiv !== len - 1)
    else if (dragDistanceY < -swipeThreshold) {
      swipeToAction(1);
    }
 
    setDragDistance(0);
  };
 
  const skipToDiv = (divId: number) => {
    let changeDirection = 1;
    if (divId > activeDiv) {
      changeDirection = 1;
    } else if (divId < activeDiv) {
      changeDirection = -1;
    }
    setDirection([divId, changeDirection]);
  };
 
  const blurValue = Math.min(Math.abs(dragDistance / 20), 10);
  const rotateYValue = Math.min(dragDistance / 10, 15);
 
  return (
    <>
      <div
        className={cn(
          "storybook-fix group flex items-center justify-center py-32",
          containerClassName,
        )}
      >
        <div
          className={cn(
            "absolute inset-0 -z-10 h-full w-full items-center bg-gradient-to-r from-violet-200 to-pink-200",
            backgroundClassName,
          )}
        />
        <AnimatePresence initial={false}>
          <div className="flex h-72 w-64 flex-col items-center">
            <div className="z-20 flex h-64 w-72 overflow-clip rounded-[35px] bg-white px-6 pt-6 shadow-[0px_0px_10px_1px_#bec2bf]">
              <motion.div
                key={activeDiv}
                custom={direction}
                className="block h-full w-full"
                variants={sliderVariants}
                initial="incoming"
                animate="active"
                transition={sliderTransition}
                drag="y"
                dragConstraints={{ top: 0, bottom: 0 }}
                dragElastic={1}
                onDragEnd={(_, dragInfo) => draghandler(dragInfo)}
                onDrag={(event, info) => setDragDistance(info.offset.y)}
                style={{
                  filter: `blur(${blurValue}px)`,
                  transform: `rotateY(${rotateYValue}deg)`,
                }}
              >
                <div className="h-56 w-56 bg-white">
                  <h1 className="mb-3 text-6xl font-bold">{funds[activeDiv].value}</h1>
                  {funds[activeDiv].change < 0 ? (
                    <h2 className="text-2xl font-bold text-red-500">
                      {funds[activeDiv].change}% &#8595;
                    </h2>
                  ) : (
                    <h2 className="text-2xl font-bold text-green-500">
                      {funds[activeDiv].change}% &#8593;
                    </h2>
                  )}
                  <h1 className="mb-2 mt-14 text-4xl font-bold text-gray-500">
                    {funds[activeDiv].label}
                  </h1>
                </div>
              </motion.div>
              <div className="-my-2 flex h-full w-10 flex-col items-center justify-center">
                {funds.map((_, index) => {
                  return (
                    <motion.span
                      key={index}
                      className="my-1 h-2 w-2 rounded-full bg-black"
                      style={{ backgroundColor: index === activeDiv ? "black" : "gray" }}
                      initial={{ height: 8 }}
                      animate={{ height: index === activeDiv ? 30 : 8 }}
                      onClick={() => skipToDiv(index)}
                    ></motion.span>
                  );
                })}
              </div>
            </div>
            <div className="z-10 -mt-8 h-10 w-[270px] rounded-b-[35px] bg-white shadow-[0px_0px_5px_1px_#bec2bf]"></div>
          </div>
        </AnimatePresence>
      </div>
    </>
  );
}

Credits

Built by Vishal