Custom Chart Component in React: From Concept to Implementation
Web DevelopmentWeb DevelopmentCanvasReactNPM

Custom Chart Component in React: From Concept to Implementation

hmd kamrul
March 15, 2024
5 min read

Creating a Custom Chart Component in React

When working on a React project, the demand for creating a performant and visually appealing chart is common. While many chart libraries like Chart.js, D3.js, or Recharts exist, sometimes you need something lightweight and tailored for your specific use case. This blog walks you through the journey of creating a Custom Chart Component in React, focusing on reducing project size and ensuring high performance.

Why Build a Custom Chart?

Popular libraries are feature-rich but can be overkill for simpler use cases. Here’s why I opted for a custom solution:

  • Performance: By crafting only the required functionality, I eliminated unnecessary overhead.
  • Customization: Full control over the design and behavior.
  • Reduced Dependency Size: Minimizing third-party dependencies keeps the project lean.

Below, I detail the implementation of the custom chart component and how to use it effectively.

Key Features of the Custom Chart Component

  • 📏 Dynamic Responsiveness: Adjusts to container size changes.
  • 🎯 Interactive Hover Points: Displays tooltips with detailed information.
  • 🎞️ Animated Lines and Areas: Smooth transitions on load.
  • 👁️ Configurable Line Visibility: Toggle visibility for different data series.

Custom Chart Component Code:



    import React, { useEffect, useRef, useState } from 'react'
    import Input_New from './Input_New'

    const CustomSalesChart = ({ data, width = 800, height = 400 }) => {
        const canvasRef = useRef(null)
        const containerRef = useRef(null)
        const [hoveredPoint, setHoveredPoint] = useState(null)
        const [animationProgress, setAnimationProgress] = useState(0)
        const [dimensions, setDimensions] = useState({ width: 800, height: 400 })
        const [visibleLines, setVisibleLines] = useState({
            totalSales: true,
            netRevenue: true,
            grossSales: true
        })
        const animationRef = useRef(null)

        // Colors exactly matching the image
        const COLORS = {
            totalSales: '#6C6CFF',
            netRevenue: '#52D47F',
            grossSales: '#FEAD01',
            grid: '#E2E4E9',
            text: '#666666',
            background: '#FFFFFF'
        }

        // Chart constants
        const PADDING = { top: 40, right: 40, bottom: 60, left: 60 }
        const POINT_RADIUS = 4
        const HOVER_POINT_RADIUS = 6

        useEffect(() => {
            const handleResize = () => {
                if (containerRef.current) {
                    const { width } = containerRef.current.getBoundingClientRect()
                    setDimensions({
                        width: width,
                        height: Math.max(400, width * 0.5)
                    })
                }
            }

            handleResize()
            window.addEventListener('resize', handleResize)
            return () => window.removeEventListener('resize', handleResize)
        }, [])

        // Animation setup
        useEffect(() => {
            const animate = (timestamp) => {
                if (!animationRef.current) {
                    animationRef.current = timestamp
                }

                const progress = Math.min((timestamp - animationRef.current) / 1000, 1)
                setAnimationProgress(progress)

                if (progress < 1) {
                    requestAnimationFrame(animate)
                }
            }

            setAnimationProgress(0)
            animationRef.current = null
            requestAnimationFrame(animate)

            return () => {
                animationRef.current = null
            }
        }, [])

        const formatDate = (date) => {
            return new Date(date).toISOString().split('T')[0]
        }

        const getVisibleDates = (data, containerWidth) => {
            if (!data || data.length === 0) return []

            let maxDates
            if (containerWidth >= 1024) {
                maxDates = 7 // Desktop
            } else if (containerWidth >= 768) {
                maxDates = 6 // Tablet
            } else if (containerWidth >= 480) {
                maxDates = 3 // Mobile
            } else {
                maxDates = 2 // Small Mobile
            }

            // If data points are less than or equal to maxDates, show all dates
            if (data.length <= maxDates) {
                return data.map(d => formatDate(d.date))
            }

            // For more data points, calculate visible dates
            const step = Math.floor((data.length - 1) / (maxDates - 1))
            const visibleDates = []

            // Always show first date
            visibleDates.push(formatDate(data[0].date))

            // Add intermediate dates
            for (let i = 1; i < maxDates - 1; i++) {
                const index = i * step
                visibleDates.push(formatDate(data[index].date))
            }

            // Always show last date
            visibleDates.push(formatDate(data[data.length - 1].date))

            return visibleDates
        }


        useEffect(() => {
            const canvas = canvasRef.current
            if (!canvas || !data || data.length === 0) return

            const ctx = canvas.getContext('2d')
            if (!ctx) return

            // Setup high DPI canvas
            const dpr = window.devicePixelRatio || 1
            const { width, height } = dimensions
            canvas.width = width * dpr
            canvas.height = height * dpr
            // canvas.style.width = `${width}px`
            canvas.style.width = '100%'
            // canvas.style.width = width > 576 ? '100%' : '800px'
            canvas.style.height = `${height}px`
            ctx.scale(dpr, dpr)

            // Clear canvas
            ctx.fillStyle = COLORS.background
            ctx.fillRect(0, 0, width, height)

            // Calculate scales
            const xScale = (width - PADDING.left - PADDING.right) / (data.length - 1)
            const yScale = (height - PADDING.top - PADDING.bottom) / 2 // -1 to 1 range

            // Draw grid lines
            ctx.strokeStyle = COLORS.grid
            ctx.lineWidth = 1
            for (let i = -10; i <= 10; i += 2) {
                const y = PADDING.top + (height - PADDING.top - PADDING.bottom) * (1 - (i + 10) / 20)
                ctx.beginPath()
                ctx.moveTo(PADDING.left, y)
                ctx.lineTo(width - PADDING.right, y)
                ctx.stroke()

                // Draw y-axis labels
                ctx.fillStyle = COLORS.text
                ctx.font = '12px Arial'
                ctx.textAlign = 'right'
                ctx.fillText((i / 10).toFixed(1), PADDING.left - 10, y + 4)
            }

            // Draw x-axis labels
            ctx.textAlign = 'center'
            ctx.fillStyle = COLORS.text
            ctx.font = '12px Arial'
            const visibleDates = getVisibleDates(data, dimensions.width)
            const dateSpacing = (width - PADDING.left - PADDING.right) / (visibleDates.length - 1)

            visibleDates.forEach((date, i) => {
                const x = PADDING.left + (i * dateSpacing)
                // Draw date without rotation
                ctx.fillText(date, x, height - PADDING.bottom + 20)
            })

            // Function to draw a straight line series
            const drawSeries = (dataKey, color) => {
                if (!visibleLines[dataKey]) return

                const points = data.slice(0, Math.ceil(data.length * animationProgress)).map((point, i) => ({
                    x: PADDING.left + i * xScale,
                    y: PADDING.top + (height - PADDING.top - PADDING.bottom) * (1 - (point[dataKey] + 1) / 2)
                }))

                if (points.length < 2) return

                // Draw area
                ctx.beginPath()
                ctx.moveTo(points[0].x, height - PADDING.bottom)
                points.forEach(point => ctx.lineTo(point.x, point.y))
                ctx.lineTo(points[points.length - 1].x, height - PADDING.bottom)
                ctx.closePath()

                const gradient = ctx.createLinearGradient(0, PADDING.top, 0, height - PADDING.bottom)
                gradient.addColorStop(0, `${color}33`)
                gradient.addColorStop(1, `${color}05`)
                ctx.fillStyle = gradient
                ctx.fill()

                // Draw line
                ctx.beginPath()
                points.forEach((point, i) => {
                    if (i === 0) ctx.moveTo(point.x, point.y)
                    else ctx.lineTo(point.x, point.y)
                })
                ctx.strokeStyle = color
                ctx.lineWidth = 4 //Chart Line Stroke Width
                ctx.stroke()

                // Draw points
                points.forEach((point) => {
                    ctx.beginPath()
                    ctx.arc(point.x, point.y, POINT_RADIUS, 0, Math.PI * 2)
                    ctx.fillStyle = color
                    ctx.fill()
                })

                return points
            }

            // Draw series
            const totalSalesPoints = drawSeries('totalSales', COLORS.totalSales)
            const netRevenuePoints = drawSeries('netRevenue', COLORS.netRevenue)
            const grossSalesPoints = drawSeries('grossSales', COLORS.grossSales)

            // Draw hover line and points
            if (hoveredPoint && hoveredPoint.dataIndex >= 0 && hoveredPoint.dataIndex < data.length) {
                // Draw vertical line
                ctx.beginPath()
                ctx.strokeStyle = COLORS.grid
                ctx.lineWidth = 6
                const x = PADDING.left + hoveredPoint.dataIndex * xScale
                ctx.moveTo(x, PADDING.top)
                ctx.lineTo(x, height - PADDING.bottom)
                ctx.stroke()

                    // Draw hover points
                    ;[
                        { points: totalSalesPoints, color: COLORS.totalSales, key: 'totalSales' },
                        { points: netRevenuePoints, color: COLORS.netRevenue, key: 'netRevenue' },
                        { points: grossSalesPoints, color: COLORS.grossSales, key: 'grossSales' }
                    ].forEach(({ points, color, key }) => {
                        if (points && points[hoveredPoint.dataIndex] && visibleLines[key]) {
                            ctx.beginPath()
                            ctx.arc(points[hoveredPoint.dataIndex].x, points[hoveredPoint.dataIndex].y, HOVER_POINT_RADIUS, 0, Math.PI * 2)
                            ctx.fillStyle = color
                            ctx.fill()
                            ctx.strokeStyle = COLORS.background
                            ctx.lineWidth = 2
                            ctx.stroke()
                        }
                    })
            }

        }, [data, dimensions, hoveredPoint, animationProgress, visibleLines])

        const handleMouseMove = (e) => {
            const canvas = canvasRef.current;
            if (!canvas || !data || data.length === 0) return;

            const rect = canvas.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;

            if (
                x < PADDING.left ||
                x > dimensions.width - PADDING.right ||
                y < PADDING.top ||
                y > dimensions.height - PADDING.bottom
            ) {
                setHoveredPoint(null);
                return;
            }

            const dataIndex = Math.round((x - PADDING.left) / ((dimensions.width - PADDING.left - PADDING.right) / (data.length - 1)));
            if (dataIndex >= 0 && dataIndex < data.length) {
                setHoveredPoint({ x, y, dataIndex });
            } else {
                setHoveredPoint(null);
            }
        };

        const handleMouseLeave = () => {
            setHoveredPoint(null)
        }

        const toggleLine = (key) => {
            setVisibleLines(prev => ({ ...prev, [key]: !prev[key] }))
        }

        return (
            <div ref={containerRef} className="wsx-card wsx-mb-40">
                <div className="wsx-sales-chart-header">
                    <h2 className="wsx-title wsx-font-20">Sales Statistics</h2>
                    <div className="wsx-sales-chart-legend">
                        {Object.entries(COLORS).slice(0, 3).map(([key, color]) => (
                            <label key={key} className="wsx-sales-chart-filter-item">
                                {(() => {
                                    let label = key.charAt(0).toUpperCase() + key.slice(1);
                                    return (
                                        <div className="wsx-d-flex wsx-item-center wsx-gap-40">
                                            <Input_New
                                                type="checkbox"
                                                inputBackground={color}
                                                labelClass="wsx-color-text-light"
                                                checkState={true}
                                                onChecked={visibleLines[key]}
                                                onChange={() => toggleLine(key)}
                                                label={label + " (B2B)"}
                                            />
                                        </div>
                                    );
                                })()}
                            </label>
                        ))
                        }
                    </div>
                </div>
                <div className="wsx-sales-chart-wrapper">
                    <canvas
                        ref={canvasRef}
                        width={dimensions.width}
                        height={dimensions.height}
                        onMouseMove={handleMouseMove}
                        onMouseLeave={handleMouseLeave}
                        className="wsx-sales-chart-canvas"
                    />
                    {hoveredPoint && hoveredPoint.dataIndex >= 0 && hoveredPoint.dataIndex < data.length && (
                        <div
                            className="wsx-sales-chart-tooltip wsx-text-space-nowrap"
                            style={{
                                left: `${hoveredPoint.x}px`,
                                top: `${hoveredPoint.y}px`,
                            }}
                        >
                            <div className="wsx-sales-chart-tooltip-date">
                                {formatDate(data[hoveredPoint.dataIndex].date)}
                            </div>
                            <div className="wsx-sales-chart-tooltip-content">
                                {Object.entries(COLORS).slice(0, 3).map(([key, color]) => (
                                    visibleLines[key] && (
                                        <div key={key} className="wsx-sales-chart-tooltip-item">
                                            <div className='wsx-d-flex wsx-item-center wsx-gap-8'>
                                                <div
                                                    className="wsx-sales-chart-tooltip-dot"
                                                    style={{ backgroundColor: color }}
                                                />
                                                <div className="wsx-sales-chart-tooltip-label">
                                                    {key.charAt(0).toUpperCase() + key.slice(1)} (B2B):
                                                </div>
                                            </div>
                                            <div className="wsx-sales-chart-tooltip-value">
                                                {(data[hoveredPoint.dataIndex][key] * 1000).toFixed(0) < 0 ? '-$'+(data[hoveredPoint.dataIndex][key] * -1000).toFixed(0) : '$'+(data[hoveredPoint.dataIndex][key] * 1000).toFixed(0)}
                                            </div>
                                        </div>
                                    )
                                ))}
                            </div>
                        </div>
                    )}
                </div>
            </div>
        )
    }

    export default CustomSalesChart


    import React from 'react';
    import CustomSalesChart from './CustomSalesChart';
 
    const ExampleUsage = () => {
        const sampleData = [
            { date: '2024-08-25', totalSales: -1.0, netRevenue: -0.6, grossSales: -0.8 },
            { date: '2024-08-26', totalSales: 0.1, netRevenue: -0.4, grossSales: -0.6 },
            { date: '2024-08-27', totalSales: 0.3, netRevenue: 0.2, grossSales: 0.0 },
            // More data points...
        ];
        return <CustomSalesChart data={sampleData} />;
    };

    export default ExampleUsage;


    .wsx-sales-chart-container {
    width: 100%;
    font-family: Arial, sans-serif;
    }

    .wsx-sales-chart-header {
    margin-bottom: 20px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 16px;
    }

    @media (min-width: 768px) {
    .wsx-sales-chart-header {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
    }
    }

    .wsx-sales-chart-title {
    font-size: 20px;
    font-weight: 600;
    margin: 0;
    }

    .wsx-sales-chart-legend {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 16px;
    }

    .wsx-sales-chart-legend-item {
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
    font-size: 14px;
    }

    .wsx-sales-chart-checkbox {
    width: 16px;
    height: 16px;
    cursor: pointer;
    }

    .wsx-sales-chart-color-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    }

    .wsx-sales-chart-wrapper {
    position: relative;
    margin-bottom: 50px;
    }

    .wsx-sales-chart-
    canvas {
    cursor: pointer;
    border: 1px solid #e2e8f0;
    width: 100%;
    height: auto;
    }

    .wsx-sales-chart-tooltip {
    position: absolute;
    z-index: 10;
    background-color: white;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    padding: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    transform: translate(-50%, -100%);
    pointer-events: none;
    }

    .wsx-sales-chart-tooltip-date {
    font-size: 14px;
    font-weight: 600;
    color: #4a5568;
    margin-bottom: 8px;
    padding-bottom: 8px;
    border-bottom: 1px solid #e2e8f0;
    }

    .wsx-sales-chart-tooltip-content {
    display: flex;
    flex-direction: column;
    gap: 8px;
    }

    .wsx-sales-chart-tooltip-item {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 14px;
    }

    .wsx-sales-chart-tooltip-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
    }

    .wsx-sales-chart-tooltip-label {
    color: #718096;
    }

    .wsx-sales-chart-tooltip-value {
    font-weight: 600;
    color: #2d3748;
    }
    

Final Thoughts

Creating a custom chart component requires a deeper understanding of React and the Canvas API. However, the effort pays off with increased flexibility, performance, and reduced dependency bloat. If you’ve been relying on third-party chart libraries, I encourage you to explore creating your own — it’s a rewarding and educational experience.

What are your thoughts on building custom components like this? Share your experience in the comments below!


Develop By - Hmd Kamrul