Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/propel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"exports": {
"./accordion": "./dist/accordion/index.js",
"./animated-counter": "./dist/animated-counter/index.js",
"./avatar": "./dist/avatar/index.js",
"./card": "./dist/card/index.js",
"./charts/*": "./dist/charts/*/index.js",
Expand Down
55 changes: 55 additions & 0 deletions packages/propel/src/animated-counter/animated-counter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AnimatedCounter } from "./animated-counter";

const meta: Meta<typeof AnimatedCounter> = {
title: "AnimatedCounter",
component: AnimatedCounter,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
size: {
control: { type: "select" },
options: ["sm", "md", "lg"],
},
},
};

export default meta;
type Story = StoryObj<typeof AnimatedCounter>;

const AnimatedCounterDemo = (args: React.ComponentProps<typeof AnimatedCounter>) => {
const [count, setCount] = useState(args.count || 0);

return (
<div className="space-y-6 p-4">
<div className="flex items-center justify-center gap-6">
<button
className="px-4 py-2 bg-red-500 text-white font-medium rounded-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 transition-colors shadow-md"
onClick={() => setCount((prev) => Math.max(0, prev - 1))}
>
-1
</button>
<div className="flex items-center justify-center min-w-[60px] h-12 bg-gray-50 border border-gray-200 rounded-lg">
<AnimatedCounter {...args} count={count} />
</div>
<button
className="px-4 py-2 bg-green-500 text-white font-medium rounded-lg hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2 transition-colors shadow-md"
onClick={() => setCount((prev) => prev + 1)}
>
+1
</button>
</div>
</div>
);
};

export const Default: Story = {
render: (args) => <AnimatedCounterDemo {...args} />,
args: {
count: 5,
size: "md",
},
};
95 changes: 95 additions & 0 deletions packages/propel/src/animated-counter/animated-counter.tsx
Original file line number Diff line number Diff line change
@@ -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<AnimatedCounterProps> = ({ 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 (
<div className={cn("relative inline-flex items-center justify-center overflow-hidden", sizeClass)}>
{/* Previous number sliding out */}
{isAnimating && (
<span
key={`prev-${animationKey}`}
className={cn(
"absolute inset-0 flex items-center justify-center font-medium",
"animate-[slideOut_0.25s_ease-out_forwards]",
direction === "up" && "[--slide-out-dir:-100%]",
direction === "down" && "[--slide-out-dir:100%]",
sizeClass
)}
style={{
animation:
direction === "up"
? "slideOut 0.25s ease-out forwards, fadeOut 0.25s ease-out forwards"
: "slideOutDown 0.25s ease-out forwards, fadeOut 0.25s ease-out forwards",
}}
>
{prevCount}
</span>
)}

{/* New number sliding in */}
<span
key={`current-${animationKey}`}
className={cn(
"flex items-center justify-center font-medium",
isAnimating && "animate-[slideIn_0.25s_ease-out_forwards]",
!isAnimating && "opacity-100",
sizeClass,
className
)}
style={
isAnimating
? {
animation:
direction === "up"
? "slideInFromBottom 0.25s ease-out forwards"
: "slideInFromTop 0.25s ease-out forwards",
}
: undefined
}
>
{displayCount}
</span>
</div>
);
};
2 changes: 2 additions & 0 deletions packages/propel/src/animated-counter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AnimatedCounter } from "./animated-counter";
export type { AnimatedCounterProps } from "./animated-counter";
1 change: 1 addition & 0 deletions packages/propel/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/card/index.ts",
"src/charts/*/index.ts",
Expand Down
54 changes: 54 additions & 0 deletions packages/tailwind-config/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading