Eunary UI, a modern component library built with React, Tailwind CSS, and Motion, providing accessible and animated components

Progress Card

Interactive progress card with smooth animations, percentage indicators, and dynamic visuals to monitor and showcase task progress.

Progress Card Component

Interactive progress cards with beautiful animations and multiple display modes

Development Sprint

Current sprint progress with detailed task breakdown

0%
Frontend Development
9 of 10 ready
Backend Integration
8 of 10 ready
DevOps
7 of 10 ready
UI/UX Design
8 of 10 ready

Installation

Install dependencies

npm install class-variance-authority motion clsx tailwind-merge @tabler/icons-react

Add util file

lib/utils.ts
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs));
}

Copy the source code

components/ui/progress-cad.tsx
'use client';

import { cn } from '@/lib/utils';
import { IconChecks } from '@tabler/icons-react';
import { motion, animate } from 'motion/react';
import { ComponentType, useEffect, useState } from 'react';

interface Task {
    icon: ComponentType<{ className?: string }>;
    label?: string;
    completed: number;
    total: number;
}

interface ProgressCardProps {
    title?: string;
    description?: string;
    themeColor?: string;
    tasks?: Task[];
    percentage?: number;
    className?: string;
}

export const ProgressCard = ({
    title,
    description,
    themeColor = '#7f9cf5',
    tasks,
    percentage,
    className,
}: ProgressCardProps) => {
    const circumference = 2 * Math.PI * 45; // radius of 45
    const strokeDasharray = circumference;

    const [displayPercentage, setDisplayPercentage] = useState(0);

    const [isDarkMode, setIsDarkMode] = useState(false);

    useEffect(() => {
        // Check initial theme
        const checkTheme = () => {
            const isDark =
                document.documentElement.classList.contains('dark') ||
                document.body.classList.contains('dark');

            if (
                !isDark &&
                !document.documentElement.classList.contains('light')
            ) {
                const savedTheme = localStorage?.getItem('theme');
                return savedTheme === 'dark';
            }

            return isDark;
        };

        setIsDarkMode(checkTheme());

        // Listen for theme changes
        const observer = new MutationObserver(() => {
            setIsDarkMode(checkTheme());
        });

        observer.observe(document.documentElement, {
            attributes: true,
            attributeFilter: ['class'],
        });

        return () => observer.disconnect();
    }, []);

    const titleColor = isDarkMode ? '#FFFFFF' : themeColor;
    const percentageColor = isDarkMode ? '#FFFFFF' : `${themeColor}cc`;

    useEffect(() => {
        if (percentage) {
            const controls = animate(0, percentage, {
                duration: 1.5,
                ease: 'easeInOut',
                delay: 0.2,
                onUpdate: (value) => {
                    setDisplayPercentage(Math.round(value));
                },
            });

            return () => controls.stop();
        }
    }, [percentage]);

    return (
        <div
            className={cn(
                'group relative overflow-hidden rounded-2xl p-6 backdrop-blur-md transition-transform duration-300 hover:scale-103',
                className
            )}
            style={{
                background: isDarkMode ? '#5252521a' : `${themeColor}1a`,
            }}
        >
            {/* Background geometric shapes */}
            <div className="absolute inset-0 opacity-10">
                <div
                    className="absolute top-8 right-12 h-16 w-16 rotate-12 rounded-lg border-2 transition-all duration-300 group-hover:scale-105 group-hover:rotate-30"
                    style={{ borderColor: themeColor }}
                ></div>
                <div
                    className="absolute right-8 bottom-12 h-12 w-12 rounded-full transition-transform duration-300 group-hover:-translate-1 group-hover:scale-125"
                    style={{ background: themeColor }}
                ></div>
                <div
                    className="absolute top-16 right-4 h-8 w-8 rotate-45 rounded border-2 transition-all duration-300 group-hover:scale-105 group-hover:rotate-12"
                    style={{ borderColor: `${themeColor}80` }}
                ></div>
            </div>

            {/* Header */}
            <div className="mb-6 flex items-start justify-between">
                <div>
                    {title && (
                        <h2
                            className="mb-2 text-xl font-semibold transition-colors duration-300"
                            style={{ color: titleColor }}
                        >
                            {title}
                        </h2>
                    )}
                    {description && (
                        <p className="text-sm leading-relaxed text-neutral-500 dark:text-neutral-300">
                            {description}
                        </p>
                    )}
                </div>

                {percentage ? (
                    <div className="mt-1 flex space-x-1">
                        {[...Array(5)].map((_, index) => {
                            const dotThreshold = (index + 1) * 20; // Each dot represents 20%
                            const isColored =
                                percentage && percentage >= dotThreshold;

                            return (
                                <motion.div
                                    key={index}
                                    className={cn(
                                        'h-1.5 w-1.5 rounded-full transition-colors duration-300'
                                    )}
                                    style={{
                                        background: isColored
                                            ? themeColor
                                            : '#a0aec0',
                                    }}
                                    initial={{ scale: 0.8, opacity: 0.5 }}
                                    animate={{
                                        scale: 1.1,
                                        opacity: 1,
                                    }}
                                    transition={{
                                        duration: 0.3,
                                        delay: index * 0.1 + 0.8,
                                        ease: 'easeOut',
                                    }}
                                />
                            );
                        })}
                    </div>
                ) : (
                    <IconChecks className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
                )}
            </div>

            {/* Circular Progress */}
            {percentage && (
                <div className="mt-4 flex justify-center">
                    <div className="relative">
                        <svg
                            width="120"
                            height="120"
                            className="-rotate-90 transform transition-all duration-300 group-hover:scale-105 group-hover:-rotate-120"
                        >
                            {/* Background circle */}
                            <circle
                                cx="60"
                                cy="60"
                                r="45"
                                stroke="rgb(55, 65, 81)"
                                strokeWidth="4"
                                fill="transparent"
                            />
                            {/* Animated Progress circle */}
                            <motion.circle
                                cx="60"
                                cy="60"
                                r="45"
                                stroke={themeColor}
                                strokeWidth="4"
                                fill="transparent"
                                strokeLinecap="round"
                                strokeDasharray={strokeDasharray}
                                initial={{ strokeDashoffset: circumference }}
                                animate={{
                                    strokeDashoffset:
                                        circumference -
                                        (percentage / 100) * circumference,
                                }}
                                transition={{
                                    duration: 1.5,
                                    ease: 'easeInOut',
                                    delay: 0.2,
                                }}
                                style={{
                                    filter: `drop-shadow(0 0 8px ${themeColor}80)`,
                                }}
                            />
                        </svg>
                        {/* Animated Percentage text */}
                        <div className="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover:scale-105">
                            <motion.span
                                className="text-3xl font-bold transition-colors duration-300"
                                style={{ color: percentageColor }}
                                initial={{ opacity: 0, scale: 0.5 }}
                                animate={{ opacity: 1, scale: 1 }}
                                transition={{
                                    duration: 0.8,
                                    ease: 'easeOut',
                                    delay: 0.5,
                                }}
                            >
                                {displayPercentage}%
                            </motion.span>
                        </div>
                    </div>
                </div>
            )}

            {/* Tasks List */}
            {tasks && tasks.length > 0 && (
                <div className="space-y-5">
                    {tasks.map((task, index) => {
                        const Icon = task.icon;
                        const progressWidth =
                            (task.completed / task.total) * 100;
                        const [isTaskHovered, setIsTaskHovered] =
                            useState(false);

                        return (
                            <motion.div
                                key={index}
                                className="relative cursor-pointer space-y-3"
                                onHoverStart={() => setIsTaskHovered(true)}
                                onHoverEnd={() => setIsTaskHovered(false)}
                                whileHover={{
                                    x: 5,
                                    transition: { duration: 0.2 },
                                }}
                            >
                                <div className="absolute inset-0 z-0" />
                                {/* Task Info */}
                                <div className="flex items-center justify-between">
                                    <div className="flex items-center space-x-3">
                                        <motion.div
                                            animate={{
                                                scale: isTaskHovered ? 1.2 : 1,
                                                rotate: isTaskHovered ? -10 : 0,
                                                color: isTaskHovered
                                                    ? 'rgb(124, 134, 255)'
                                                    : 'rgb(156, 163, 175)',
                                            }}
                                            transition={{ duration: 0.3 }}
                                        >
                                            <Icon className="h-5 w-5 text-gray-400" />
                                        </motion.div>
                                        <span className="font-medium text-gray-600 dark:text-white">
                                            {task.label}
                                        </span>
                                    </div>
                                    <motion.span
                                        className="text-sm text-neutral-500 dark:text-neutral-300"
                                        animate={{
                                            opacity: isTaskHovered ? 1 : 0.8,
                                            scale: isTaskHovered ? 1.05 : 1,
                                        }}
                                        transition={{ duration: 0.2 }}
                                    >
                                        {task.completed} of {task.total} ready
                                    </motion.span>
                                </div>

                                {/* Animated Progress Bar */}
                                <div className="relative">
                                    {/* Background dashed line */}
                                    <div
                                        className="h-0.5 border-t-2 border-dashed border-gray-600"
                                        style={{ width: '100%' }}
                                    ></div>

                                    {/* Animated Progress line */}
                                    <motion.div
                                        className="absolute top-0 h-0.5"
                                        initial={{ width: '0%' }}
                                        animate={{
                                            width: `${progressWidth}%`,
                                            height: isTaskHovered
                                                ? '2px'
                                                : '2px',
                                        }}
                                        transition={{
                                            duration: 1.2,
                                            ease: 'easeOut',
                                            delay: 0.8 + index * 0.1,
                                        }}
                                        style={{
                                            filter: isTaskHovered
                                                ? `drop-shadow(0 0 8px ${themeColor}80)`
                                                : `drop-shadow(0 0 4px ${themeColor}80)`,
                                            boxShadow: isTaskHovered
                                                ? `0 0 8px ${themeColor}80`
                                                : 'none',
                                            background: themeColor,
                                        }}
                                    />
                                </div>
                            </motion.div>
                        );
                    })}
                </div>
            )}
        </div>
    );
};

Props

Use the following props to configure the progress card.

PropTypeDescription
titlestringTitle displayed at the top of the card.
descriptionstringShort description or subtitle below the title.
themeColorstringCustom theme color in hex code for progress ring, accents, and highlights.
percentagenumberOverall completion percentage (0–100). If provided, circular progress is shown.
taskstask[]List of tasks with icon, label, and completion data.
classNamestringAdditional CSS classes to customize the card container styling.
Task Interface
PropTypeDescription
iconComponentTypeIcon component displayed alongside the task label.
labelstringLabel of the task.
completednumberNumber of completed items for this task.
totalnumberTotal number of items for this task.

Explore more components with Eunary

Discover and experiment with a variety of components to craft a stunning and seamless experience for your product.

Eunary UI, a modern component library built with React, Tailwind CSS, and Motion, providing accessible and animated components

Eunary

UI

Product by Eunary

Building in public by Hemant Sharma