Creating an Animated and Clickable Pie Chart With React Native Skia


Charts are a great way to visualize your data. For React Native, there are already some excellent chart libraries with ready-to-use charts like Bar, Line, or Pie. In my recent projects, I used Victory Native XL, a rewrite of Victory for React Native using React Native Skia, React Native Reanimated, and React Native Gesture Handler. They offer some great charts, but for one project, I needed an animated Pie (Donut) Chart where users can click on pie slices and get visual feedback. This was not straightforward, so I decided to implement the chart myself using Skia and Reanimated, using Victory Native as inspiration.
In the video below you can see the implementation of the chart.
The chart animates each slice from its starting angle to its ending angle. When a user clicks on a slice, it visually highlights by increasing the stroke width of the selected slice and decreases the stroke width of all other slices. There is also a callback to indicate which slice has been clicked, allowing for a response, such as showing more data or triggering another animation (e.g., displaying a tooltip with additional information).
Let’s take a look at the implementation!
Animating the Pie Slices
React Native Skia works well with Reanimated. You can simply pass a ShareValue for animations that holds a Skia type, like SKPath in our case. To create a pie slice, we will use the Path
component exported from Skia. We need a little information to draw the slices:
x
andy
coordinates for the pathwidth
andheight
for the pathstartAngle
andendAngle
for the chart - these are actual circle angles 0° - 360°
To gather these informations, we can take a look at our input data and prepare each slice for the draw.
const GAP = 8;
const SIZE = 260; // Base SIZE
const BASE_STROKE_WIDTH = 12;
const MAX_STROKE_WIDTH = BASE_STROKE_WIDTH * 1.5; // Maximum stroke width during animation
const PADDING = MAX_STROKE_WIDTH + 4; // Add a little extra space
const ADJUSTED_SIZE = SIZE + PADDING * 2; // Increase canvas SIZE
const CENTER = ADJUSTED_SIZE / 2; // New CENTER point
// Adjust radius to maintain same visible SIZE
const RADIUS = SIZE / 2;
const pieChartSlices = useMemo(() => {
let currentAngle = -90; // Start from top
return data.map((item, index) => {
const proportion = item.value / totalValue;
const fullSweepAngle = proportion * 360;
const segmentStart = currentAngle; // Start angle of the slice
currentAngle += fullSweepAngle; // Tracking of the current angle for the next slice
return {
startAngle: segmentStart,
fullSweepAngle,
item: item,
index: index,
radius: RADIUS,
center: CENTER,
gap: GAP,
strokeWidth: BASE_STROKE_WIDTH,
};
});
}, [data]);
Based on the proportion of each slice, we calculate the fullSweepAngle (the end angle, used for the animation later) and the start angle. Our chart should begin from the top. Additionally, we add details like the width of the gap, the radius, and the base stroke width for each slice.
With this information, we can draw the arc for each slice of our chart.
import { Skia } from '@shopify/react-native-skia';
export const createArcPath = (args: {
startAngle: number;
endAngle: number;
radius: number;
center: number;
strokeWidth: number;
}) => {
'worklet';
const { startAngle, endAngle, radius, center, strokeWidth } = args;
const path = Skia.Path.Make();
path.addArc(
{
x: center - radius + strokeWidth / 2,
y: center - radius + strokeWidth / 2,
width: radius * 2 - strokeWidth,
height: radius * 2 - strokeWidth,
},
startAngle,
endAngle - startAngle,
);
return path;
};
We declare the draw function here as a worklet
because we want to animate each slice's entrance. For this animation, we will create a Shared Value using useDerivedValue
. This provides us with our animated path, which we can then draw onto the canvas.
import { usePieSliceContext } from "@/contexts/pie-slice-context";
import { SPRING_CONFIG } from "@/lib/charts/animation";
import { createArcPath } from "@/lib/charts/draw";
import { Path } from "@shopify/react-native-skia";
import {
SharedValue,
useDerivedValue,
withSpring,
} from "react-native-reanimated";
export interface PieChartDataEntry {
value: number;
color: string;
label: string;
}
export interface PieSliceData {
item: PieChartDataEntry;
startAngle: number;
index: number;
radius: number;
center: number;
fullSweepAngle: number;
gap: number;
animatedValue: SharedValue<number>;
strokeWidth: number;
selectedSlice: SharedValue<number | null>;
}
export function PieSlice(props:PieSliceData) {
const {
index,
item,
startAngle,
fullSweepAngle,
gap,
animatedValue,
radius,
center,
strokeWidth,
selectedSlice,
} = slice;
const path = useDerivedValue(() => {
const animatedSweep = Math.max(
0,
(fullSweepAngle - gap) * animatedValue.value
);
return createArcPath({
startAngle: startAngle,
endAngle: startAngle + animatedSweep,
radius,
center: center,
strokeWidth: strokeWidth,
});
});
return (
<Path
path={path}
color={item.color}
style="stroke"
strokeWidth={strokeWidth}
strokeCap="round"
/>
);
}
The animated value is shared to all slices and as declared in the parent component.
This gives us a nice animated entrance of each slice and it’s reactive to any data change. In the current implementation, we would re-draw and reanimate the whole Pie, but that can easily be changed to only animate the actual data change.
Gesture Tracking With React Native Skia
Before we can implement the gesture handling, let’s have a look how gestures work with React Native Skia. Skia integrates well with Reanimated, so it is recommended to use the Gesture Handler here. You can easily wrap the Canvas in a GestureDetector. A common use-case is to only have gestures for certain element on the Canvas. This can be done by a technique Skia calls Element Tracking. To track elements on a Canvas, we overlay an animated view on it, ensuring that the same transformations applied to the canvas element are mirrored on the animated view. You can read more about this on the official documentation. For our pie chart, that is not really applicable. We are drawing some complex graphics here, and if we could just use a View, we wouldn’t need Skia in the first place.
We could use an SVG Path to overlay it each slice, as we can generate a SVG path from Skia Path, but we would also need to consider animations an other stuff here. So, I decided to implement this a little different.
We have all the information to draw our slices on the Canvas. Next, we can wrap the Canvas in a GestureDetector
. This will help us track touch coordinates and see if the touch is on one of the slices. It's straightforward since we are dealing with a circle. We can easily determine if a point is on a circle using geometric principles.
Adding the Gesture Handling
We want to achieve two goals: First, register a click on any slice of the pie chart. This will invoke a callback with details about the clicked slice. Second, provide visual feedback by highlighting the selected slice. I did this by increasing the stroke width of the selected slice and decreasing it for all other slices.
First of, we need to wrap the Canvas in a GestureDetector
to register any touches on the Canvas.
<View
style={{
width: "100%",
flex: 1,
height: 300,
justifyContent: "center",
alignItems: "center",
borderRadius: 10,
}}
>
<GestureDetector gesture={tap}>
<Canvas
style={{
width: ADJUSTED_SIZE,
height: ADJUSTED_SIZE,
}}
>
<Group>
{pieChartSlices.map((slice, index) => (
<PieSlice
{...slice}
index={index}
animatedValue={pieAnimation}
selectedSlice={selectedSlice}
/>
))}
</Group>
</Canvas>
</GestureDetector>
</View>
Now we need a way to detect if the touch area was within or on a slice of the chart, or if it was outside of the chart, e.g. somewhere else on the canvas.
export const checkIfDistanceIsInsideArc = (args: {
centerX: number;
centerY: number;
radius: number;
strokeWidth: number;
x: number;
y: number;
}) => {
'worklet';
const { centerX, centerY, radius, strokeWidth, x, y } = args;
const dx = x - centerX;
const dy = y - centerY;
// Calculate distance from center
const distance = Math.sqrt(dx * dx + dy * dy);
// Add padding to the hit area
const touchPadding = 15;
const innerRadius = radius - strokeWidth / 2 - touchPadding;
const outerRadius = radius + strokeWidth / 2 + touchPadding;
return distance >= innerRadius && distance <= outerRadius;
};
We can use simple math to check if the click coordinates are within the pie (circle). The distance of the touch should be greater than the innerRadius and less than the outerRadius. To improve touch detection, we add a touch padding to increase the hit area on the screen.
Next, we use some circle math to calculate the angle of the touch by using its x and y coordinates. We do this by calculating the distance to the center and using that to calculate the two-argument arctangent.
export const calculateTouchAngle = (args: {
x: number;
y: number;
centerX: number;
centerY: number;
}) => {
"worklet";
const { x, y, centerX, centerY } = args;
const dx = x - centerX;
const dy = y - centerY;
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
if (angle < 0) angle += 360;
return angle;
};
// Helper function to check if a point is within an arc's bounds
export const isPointInArc = (args: {
x: number;
y: number;
centerX: number;
centerY: number;
radius: number;
startAngle: number;
endAngle: number;
}) => {
"worklet";
const {
x,
y,
centerX,
centerY,
radius,
startAngle,
endAngle,
} = args;
const angle = calculateTouchAngle({ x, y, centerX, centerY });
// Check if angle is within arc bounds
if (startAngle <= endAngle) {
return angle >= startAngle && angle <= endAngle;
} else {
// If angle is less than endAngle, add 360 to it for proper comparison
const normalizedAngle = angle <= endAngle ? angle + 360 : angle;
return normalizedAngle >= startAngle && normalizedAngle <= endAngle + 360;
}
};
If the touch angle is greater than the startAngle
and less than the endAngle
of the slice, the touch was on this specific slice. In that case, we have found our slice, and we can animate the stroke width and trigger the callback.
For the animation, we use a SharedValue that holds the index of the currently selected slice. We provide this information to each slice and animate whenever that value changes.
export const SPRING_CONFIG: SpringConfig = {
mass: 1,
damping: 15,
stiffness: 130,
};
const animatedStrokeWidth = useDerivedValue(() => {
if (selectedSlice.value === null) {
return withSpring(strokeWidth, SPRING_CONFIG);
}
return withSpring(
selectedSlice.value === index ? strokeWidth * 1.5 : strokeWidth * 0.8,
SPRING_CONFIG
);
});
// Other code from the PieSlice ...
return (
<Path
path={path}
color={item.color}
style="stroke"
strokeWidth={animatedStrokeWidth}
strokeCap="round"
/>
);
Bringing it all together gives us the final result. Any touch on a slice will increase the stroke width of that slice and decrease the width of all the other slices. If the same slice is touched again, we deselect it and animate all slices' stroke widths back to the base value.
Final Thoughts
The seamless integration of Reanimated into React Native Skia makes it very easy and quick to create beautiful, animated graphs. Compared to older methods like using SVG, Skia offers major performance improvements and fewer restrictions for creating complex 2D graphics. Gesture handling isn't as straightforward with Skia since it's essentially just a drawing on a Canvas and separate from React Native's Gesture Responder System. However, with some tricks, we can still create great interactions with our graphics.
If you want to check out the full code, you can find it in this GitHub repository.
See you next time. 👋
Subscribe to my newsletter
Read articles from Florian Fuchs directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Florian Fuchs
Florian Fuchs
I am a fullstack engineer working @ Hashnode. Building serverless things with different tools. JavaScript Enthusiast👨💻. Into AWS (CDK), TypeScript, React, React Native ... Based in Munich 🇩🇪