
Custom Chart Component in React: From Concept to Implementation
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!
About the Author

hmd kamrul
Full Stack Software Engineer