diff --git a/packages/propel/package.json b/packages/propel/package.json index bc0888176..f72f1fbc5 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -17,6 +17,7 @@ }, "exports": { "./accordion": "./dist/accordion/index.js", + "./animated-counter": "./dist/animated-counter/index.js", "./avatar": "./dist/avatar/index.js", "./calendar": "./dist/calendar/index.js", "./card": "./dist/card/index.js", diff --git a/packages/propel/src/animated-counter/animated-counter.stories.tsx b/packages/propel/src/animated-counter/animated-counter.stories.tsx new file mode 100644 index 000000000..fd93fe325 --- /dev/null +++ b/packages/propel/src/animated-counter/animated-counter.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnimatedCounter } from "./animated-counter"; + +const meta: Meta = { + title: "AnimatedCounter", + component: AnimatedCounter, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "select" }, + options: ["sm", "md", "lg"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const AnimatedCounterDemo = (args: React.ComponentProps) => { + const [count, setCount] = useState(args.count || 0); + + return ( +
+
+ +
+ +
+ +
+
+ ); +}; + +export const Default: Story = { + render: (args) => , + args: { + count: 5, + size: "md", + }, +}; diff --git a/packages/propel/src/animated-counter/animated-counter.tsx b/packages/propel/src/animated-counter/animated-counter.tsx new file mode 100644 index 000000000..09432358a --- /dev/null +++ b/packages/propel/src/animated-counter/animated-counter.tsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from "react"; +import { cn } from "../utils"; + +export interface AnimatedCounterProps { + count: number; + className?: string; + size?: "sm" | "md" | "lg"; +} + +const sizeClasses = { + sm: "text-xs h-4 w-4", + md: "text-sm h-5 w-5", + lg: "text-base h-6 w-6", +}; + +export const AnimatedCounter: React.FC = ({ count, className, size = "md" }) => { + // states + const [displayCount, setDisplayCount] = useState(count); + const [prevCount, setPrevCount] = useState(count); + const [isAnimating, setIsAnimating] = useState(false); + const [direction, setDirection] = useState<"up" | "down" | null>(null); + const [animationKey, setAnimationKey] = useState(0); + + useEffect(() => { + if (count !== prevCount) { + setDirection(count > prevCount ? "up" : "down"); + setIsAnimating(true); + setAnimationKey((prev) => prev + 1); + + // Update the display count immediately, animation will show the transition + setDisplayCount(count); + + // End animation after CSS transition + const timer = setTimeout(() => { + setIsAnimating(false); + setDirection(null); + setPrevCount(count); + }, 250); + + return () => clearTimeout(timer); + } + }, [count, prevCount]); + + const sizeClass = sizeClasses[size]; + + return ( +
+ {/* Previous number sliding out */} + {isAnimating && ( + + {prevCount} + + )} + + {/* New number sliding in */} + + {displayCount} + +
+ ); +}; diff --git a/packages/propel/src/animated-counter/index.ts b/packages/propel/src/animated-counter/index.ts new file mode 100644 index 000000000..86b4c39b8 --- /dev/null +++ b/packages/propel/src/animated-counter/index.ts @@ -0,0 +1,2 @@ +export { AnimatedCounter } from "./animated-counter"; +export type { AnimatedCounterProps } from "./animated-counter"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index f559c0017..ac439f1b6 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: [ "src/accordion/index.ts", + "src/animated-counter/index.ts", "src/avatar/index.ts", "src/calendar/index.ts", "src/card/index.ts", diff --git a/packages/tailwind-config/global.css b/packages/tailwind-config/global.css index 1cf516922..4e3d5c525 100644 --- a/packages/tailwind-config/global.css +++ b/packages/tailwind-config/global.css @@ -694,3 +694,57 @@ div.web-view-spinner div.bar12 { .disable-autofill-style:-webkit-autofill:active { -webkit-background-clip: text; } + + +@keyframes slideInFromBottom { + 0% { + transform: translateY(100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInFromTop { + 0% { + transform: translateY(-100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideOut { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(-100%); + opacity: 0; + } +} + +@keyframes slideOutDown { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(100%); + opacity: 0; + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} \ No newline at end of file