Charts & Animations
\ # Chart Animations
How we implement smooth transitions in LibreFolio's ECharts-based charts.
Architecture
echartsAnimationConfig.ts ← Centralized config + helpers
├── CHART_ANIMATION_CONFIG ← Timing/easing settings
├── CHART_SET_OPTION_OPTS ← setOption flags (notMerge/replaceMerge)
└── namedPoint(date, value) ← Named data point for shift animation
Two Animation Strategies
1. Dashboard charts (GrowthChart, AllocationHistoryChart)
These use shift animation — when the time period changes, shared dates translate to new positions and new dates appear/disappear smoothly.
Requirements:
xAxis: { type: 'time' }— ECharts understands temporal coordinates- Data as
namedPoint(date, value)→{name: date, value: [date, val]} - Partial
setOption({series: [...]})on data-only updates (no full option rebuild) - Full
setOption(option, CHART_SET_OPTION_OPTS)only on init or mode/dark change
Key insight (from ECharts developers):
ECharts performs shift animation if it recognizes part of the old data in the new data. This matching is done via the
nameproperty on each data point.
// echartsAnimationConfig.ts
export function namedPoint(date: string, value: number | null) {
return {name: date, value: [date, value]};
}
Partial vs Full update pattern:
// GrowthChart.svelte — renderChart()
const needsFullInit = lastRenderedMode !== viewMode || lastRenderedDark !== isDark;
if (!needsFullInit && chartInstance) {
// Data-only: ECharts diffs named points → shift animation
chartInstance.setOption({
series: seriesData.map(s => ({name: s.name, data: s.data})),
});
} else {
// Full init: axes, tooltip, legend, series
chartInstance.setOption(fullOption, CHART_SET_OPTION_OPTS);
}
2. Detail page charts (LineChart, CandlestickChart, PriceChartFull)
These use instant rendering (animation: false) because:
- Complex segmented series (baseline coloring, stale gradient) create N sub-series with variable count between renders
- ECharts cannot reliably match segments across period changes
- The visual priority is precision, not animation
For these charts, the UX pattern is: keep old data visible until new data arrives (stale-while-revalidate at the store level), then swap instantly.
Pie Charts (AllocationPieChart)
Uses segment morph animation:
- Partial
setOption({series: [{data: [{name, value}]}]})for data updates - ECharts matches pie segments by
name→ animates area transitions - Full option only on init or dark mode change
Numeric Values (TweenedValue)
The TweenedValue.svelte component interpolates between numbers:
- Uses Svelte's
tweened()store withcubicOuteasing - Default duration: 900ms
- Applied to: 3 hero KPI values + 11 metric bar values = 14 animated numbers
KPI Bars (CSS transitions)
KpiMetricBar: bar width + marker position animate viatransition-all duration-700KpiDivergingFlowBar: deposit/withdraw bars animate independently- Color changes:
transition-colors duration-300
Stale-While-Revalidate Pattern
Dashboard loading states are designed to never blank the screen on subsequent visits:
// summaryLoading only true when NO data exists yet
let summaryLoading = $derived(reportLoading && !summary);
When the user changes period: 1. Old data stays visible (summaryLoading = false because summary ≠ null) 2. Backend fetch runs in background 3. New data arrives → reactive updates trigger → charts animate to new values
Configuration Reference
// echartsAnimationConfig.ts
export const CHART_ANIMATION_CONFIG = {
animation: true,
animationDuration: 600, // New elements enter
animationDurationUpdate: 800, // Existing elements update
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
};
export const CHART_SET_OPTION_OPTS = {
notMerge: false, // Enable diffing against previous state
replaceMerge: ['series'], // Replace series (matched by name) with animation
};
DOM Persistence
Chart containers must never be destroyed between data updates:
<!-- ✗ WRONG: container destroyed when loading changes -->
{#if loading}
<Skeleton />
{:else}
<div bind:this={chartContainer}></div>
{/if}
<!-- ✓ CORRECT: container always present, skeleton as overlay -->
<div class="relative">
{#if loading}
<div class="absolute inset-0 z-10"><Skeleton /></div>
{/if}
<div bind:this={chartContainer} class:invisible={loading}></div>
</div>
If the DOM element is destroyed, the ECharts instance is lost and must be recreated from scratch — eliminating any possibility of transition animation.