📖 Narrative structure — the data arc
An effective Data-Story doesn’t start with the chart — it starts with the problem. The viewer needs to understand what's at stake before seeing the number. The sequence of scenes automatically creates this narrative arc when you order the Series well.
📈 The 5-scene arc
💡 The 5–8-second-per-scene rule
Each data scene should last between 5 and 8 seconds. Less than 5 s: the viewer can't process the data. More than 8 s: the pacing slows and it feels like a PowerPoint presentation. Cross-dissolve transitions of 10–15 frames cover the change without interrupting the flow.
📊 Animated bar chart — chart-animation
O chart-animation.tsx uses an array of values and animates each bar growing from 0 to its proportional height. The stagger between bars makes the chart look like it’s “filling up” — the viewer’s eye follows the tallest bar, which is usually the most important data point.
const data = [42, 68, 55, 91, 37]; // valores % const maxH = 200; // px de altura máxima {data.map((val, i) => { const delay = i * 6; // stagger de 6 frames const barHeight = interpolate( frame, [delay, delay + 45], [0, (val / 100) * maxH], { extrapolateRight: "clamp", easing: Easing.out(Easing.quad) } ); // rótulo aparece quando barra chega ao topo const labelOpacity = interpolate( frame, [delay + 40, delay + 55], [0, 1], { extrapolateRight: "clamp" } ); return <div key={i} style={{ height: `${barHeight}px`, opacity: 1 }}> <span style={{ opacity: labelOpacity }}>{val}%</span> </div>; })}
✓ Bars that work
- ✓Stagger of 4–8 frames between bars — noticeable without feeling drawn out.
- ✓The label appears when the bar finishes — 15 frames after the bar delay.
- ✓Easing.out for growth — starts fast and slows down as it reaches the top.
✗ Chart mistakes
- ✗All bars growing at the same time—loses the reveal effect.
- ✗The label appears before the bar finishes — it looks like a bug.
- ✗Easing.in for growth — starts too slowly and feels stuck.
🍩 Proportion donut chart
O donut-chart.tsx uses SVG with stroke-dasharray e stroke-dashoffset animated. Each slice is a <circle> with arc length calculated from the percentage. The progressive reveal follows the same logic as the bars: Easing.out, revealing each slice with a small stagger.
Calculate circumference and offsets
const c = 2 * Math.PI * r — circumference of the circle. For a 42% slice, the arc length is c * 0.42. O strokeDasharray é "arco restante" and the offset is rotated to position it correctly.
strokeDashoffset animation
Starts with dashoffset = c (invisible slice) and animates up to dashoffset = c - arco (complete slice). The result is the slice being "drawn" clockwise.
Caption appears after reveal
Each donut color has a legend that fades in when its corresponding slice finishes appearing. The legend and slice stay visually connected.
🔢 stat-counter and text-highlight combined
The central data scene combines two templates: stat-counter.tsx with the number animating from 0 to the value, and text-highlight.tsx that highlights a contextual phrase with a colored background growing from 0% to 100% width. The two enter in a stagger — first the number, then the text.
const frame = useCurrentFrame(); // número conta de 0 → 68% em 60 frames const pct = Math.round(interpolate( frame, [0, 60], [0, 68], { easing: Easing.out(Easing.cubic), extrapolateRight: "clamp" } )); // highlight cresce de 0 → 100% a partir do frame 50 const hlWidth = interpolate( frame, [50, 80], [0, 100], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); return <AbsoluteFill style={{ display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center" }}> <span style={{ fontSize: 120, fontWeight: 800, color: "#fbbf24" }}>{pct}%</span> <div style={{ position: "relative" }}> <div style={{ position:"absolute", inset:0, background:"rgba(251,191,36,0.25)", width:`${hlWidth}%` }}/> <span>dos vídeos são assistidos sem som</span> </div> </AbsoluteFill>;
⚔️ Comparison chart — comparison-chart
O comparison-chart.tsx shows two side-by-side bars that grow at different rates — the most powerful visual argument for showing a difference. The golden rule: the “bad” bar appears first, grows a little, and then the “good” bar dramatically overtakes it.
✓ Impactful comparison
- ✓Negative bar (red) appears first — low anchor.
- ✓Positive bar (amber/green) grows afterward and overtakes it — visual drama.
- ✓Percentage difference label appears with a spring animation above the taller bar.
✗ Weak comparison
- ✗Both bars growing together—loses the “overtaking” effect.
- ✗Similar colors on both sides — the comparison isn’t obvious.
- ✗Without a difference label, the viewer has to calculate it mentally.
🔀 Cross-dissolve transitions between scenes
Cross-dissolve is the ideal transition for Data-Story: fluid enough not to disrupt the train of thought, while clearly marking the scene change. It is implemented by overlapping two Sequence components and animating their opacities in opposite directions during the overlapping frames.
// Cena A: frames 0–90 | Cena B: frames 78–168 | overlap: 78–90 const DISSOLVE = 12; // frames de sobreposição return ( <AbsoluteFill> <Sequence from={0} durationInFrames={90}> <div style={{ opacity: interpolate(frame, [90 - DISSOLVE, 90], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}> <CenaA /> </div> </Sequence> <Sequence from={90 - DISSOLVE}> <div style={{ opacity: interpolate(frame, [90 - DISSOLVE, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}> <CenaB /> </div> </Sequence> </AbsoluteFill> );
💡 10–15 frames of dissolve
A 10-frame dissolve (0.33 s at 30fps) is almost imperceptible but softens the cut. A 15-frame dissolve (0.5 s) is noticeable and more elegant. Above 20 frames, it starts to feel intentionally dramatic — use it only if you want that effect.
🏁 Module Summary
Next Module:
4.4 — Reels / Shorts: 9:16 vertical video, typewriter, glitch, sound-wave, bokeh, and subscribe reminder.