Stacked Area
Composition over time, stacked or 100%; bands morph between modes and an interactive legend isolates series.
Visits by channel
Click a legend item to isolate it, shift-click to toggle
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/stacked-area.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/stacked-area/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { Band, StackedSeries } from './stack'
import { animate, AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { bandPath, edgePath, lerp, niceTicks, stack } from './stack'
const props = withDefaults(defineProps<{
/** One label per period (x axis). */
labels: string[]
/** Series stacked bottom-up, each with one value per label. */
series: StackedSeries[]
/** Chart height in px (legend excluded). Width follows the container. */
height?: number
/** Smooth monotone curves instead of straight segments. */
smooth?: boolean
/** Show the interactive legend. */
legend?: boolean
locale?: string
formatValue?: (value: number) => string
class?: HTMLAttributes['class']
}>(), {
height: 300,
smooth: true,
legend: true,
locale: 'en-US',
})
/** `stacked` shows absolute values, `percent` normalises every period to 100%. */
const mode = defineModel<'stacked' | 'percent'>('mode', { default: 'stacked' })
/** Names of hidden series. */
const hidden = defineModel<string[]>('hidden', { default: () => [] })
const MORPH = { duration: 0.7, ease: [0.32, 0.72, 0, 1] } as const
const TIP = { type: 'spring', stiffness: 520, damping: 40, mass: 0.6 } as const
const LEFT = 46
const RIGHT = 10
const TOP = 10
const BOTTOM = 26
const root = ref<HTMLElement>()
const width = ref(640)
const prefersReduced = useReducedMotion()
// useReducedMotion() only settles after mount, so also read the media query directly: animations
// that start on mount get skipped too. Only transitions use it — `initial` must match the server render.
const reduce = computed(() => prefersReduced.value
|| (typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches))
const orInstant = <T>(t: T) => (reduce.value ? { duration: 0 } : t)
const colorOf = (s: StackedSeries, k: number) => s.color ?? `var(--chart-${(k % 5) + 1})`
const n = computed(() => props.labels.length)
const fmtValue = computed(() => {
if (props.formatValue)
return props.formatValue
const f = new Intl.NumberFormat(props.locale, { notation: 'compact', maximumFractionDigits: 1 })
return (v: number) => f.format(v)
})
const fmtPct = (v: number) => `${(v * 100).toFixed(v > 0 && v < 0.1 ? 1 : 0)}%`
// --- target layout + tween --------------------------------------------------------
const target = computed(() => {
const s = stack(props.series, n.value, new Set(hidden.value), mode.value === 'percent')
const nice = mode.value === 'percent' ? { ticks: [0, 0.25, 0.5, 0.75, 1], top: 1 } : niceTicks(s.max)
return { ...s, ...nice }
})
// What's on screen, as fractions of the plot height (so a change of scale morphs smoothly too).
// Starts flat on the baseline and morphs to every new target.
const flat = (): Band[] => props.series.map(() => ({ y0: Array.from({ length: n.value }, () => 0), y1: Array.from({ length: n.value }, () => 0) }))
const shown = ref<Band[]>(flat())
let tween: { stop: () => void } | undefined
function morph() {
tween?.stop()
const { bands, top } = target.value
const to = bands.map(b => ({ y0: b.y0.map(v => v / (top || 1)), y1: b.y1.map(v => v / (top || 1)) }))
if (reduce.value) {
shown.value = to
return
}
const sameShape = shown.value.length === to.length && shown.value[0]?.y0.length === n.value
const from = sameShape ? shown.value : flat()
tween = animate(0, 1, {
...MORPH,
onUpdate: (t: number) => {
shown.value = to.map((b, k) => ({
y0: b.y0.map((v, i) => lerp(from[k]!.y0[i]!, v, t)),
y1: b.y1.map((v, i) => lerp(from[k]!.y1[i]!, v, t)),
}))
},
})
}
const mounted = ref(false)
watch(target, () => mounted.value && morph())
// --- geometry ------------------------------------------------------------------------
const plotW = computed(() => Math.max(40, width.value - LEFT - RIGHT))
const bottom = computed(() => props.height - BOTTOM)
const x = (i: number) => LEFT + (n.value > 1 ? (i / (n.value - 1)) * plotW.value : plotW.value / 2)
/** Fraction of the plot height → px. */
const y = (f: number) => bottom.value - f * (bottom.value - TOP)
const layers = computed(() => props.series.map((s, k) => {
const b = shown.value[k]
const top: [number, number][] = b ? b.y1.map((v, i) => [x(i), y(v)]) : []
const base: [number, number][] = b ? b.y0.map((v, i) => [x(i), y(v)]) : []
return {
name: s.name,
color: colorOf(s, k),
area: bandPath(top, base, props.smooth),
edge: edgePath(top, props.smooth),
visible: !hidden.value.includes(s.name),
}
}))
// Ticks sit at their final positions and cross-fade when the scale changes.
const yTicks = computed(() => ({
key: `${mode.value}:${target.value.top}`,
ticks: target.value.ticks.map(v => ({
v,
y: y(v / (target.value.top || 1)),
label: mode.value === 'percent' ? fmtPct(v) : fmtValue.value(v),
})),
}))
const xTicks = computed(() => {
const every = Math.max(1, Math.ceil((n.value * 56) / plotW.value))
return props.labels
.map((label, i) => ({ label, i, x: x(i) }))
.filter(t => t.i % every === 0)
})
// --- hover / keyboard -----------------------------------------------------------------
const hoverIndex = ref<number | null>(null)
const focusSeries = ref<string | null>(null)
function onMove(e: PointerEvent) {
const r = (e.currentTarget as Element).getBoundingClientRect()
const px = e.clientX - r.left
hoverIndex.value = Math.round(((px) / plotW.value) * (n.value - 1))
hoverIndex.value = Math.max(0, Math.min(n.value - 1, hoverIndex.value))
}
function onKey(e: KeyboardEvent) {
const step = { ArrowLeft: -1, ArrowRight: 1, Home: -Infinity, End: Infinity }[e.key]
if (e.key === 'Escape')
hoverIndex.value = null
if (step === undefined)
return
e.preventDefault()
const from = hoverIndex.value ?? (step > 0 ? -1 : n.value)
hoverIndex.value = Math.max(0, Math.min(n.value - 1, from + step))
}
const readout = computed(() => {
const i = hoverIndex.value
if (i === null)
return null
const totals = target.value.totals
const rows = props.series
.map((s, k) => ({ name: s.name, color: colorOf(s, k), value: s.values[i] ?? 0, k }))
.filter(r => !hidden.value.includes(r.name))
.reverse() // top of the stack first, like the chart
.map(r => ({ ...r, share: totals[i] ? r.value / totals[i]! : 0 }))
return { label: props.labels[i] ?? '', rows, total: totals[i] ?? 0 }
})
const dots = computed(() => {
const i = hoverIndex.value
if (i === null)
return []
return props.series
.map((s, k) => ({ name: s.name, color: colorOf(s, k), cy: y(shown.value[k]?.y1[i] ?? 0) }))
.filter(d => !hidden.value.includes(d.name))
})
const tip = computed(() => {
const i = hoverIndex.value
if (i === null)
return null
const px = x(i)
const flipX = px > LEFT + plotW.value / 2
return { x: px + (flipX ? -12 : 12), y: TOP, flipX }
})
// --- legend: click isolates a series (click again to show all), shift-click toggles --------
function onLegend(name: string, e: MouseEvent) {
const all = props.series.map(s => s.name)
if (e.shiftKey || e.metaKey || e.ctrlKey) {
const next = hidden.value.includes(name) ? hidden.value.filter(h => h !== name) : [...hidden.value, name]
hidden.value = next.length >= all.length ? [] : next
return
}
const isolated = hidden.value.length === all.length - 1 && !hidden.value.includes(name)
hidden.value = isolated ? [] : all.filter(a => a !== name)
}
const fillOpacity = (name: string) => (!focusSeries.value ? 0.32 : focusSeries.value === name ? 0.6 : 0.1)
const summary = computed(() => {
const last = n.value - 1
const totals = target.value.totals
const shares = props.series
.filter(s => !hidden.value.includes(s.name))
.map(s => `${s.name} ${fmtPct(totals[last] ? (s.values[last] ?? 0) / totals[last]! : 0)}`)
return `${mode.value === 'percent' ? '100% stacked' : 'Stacked'} area chart of ${props.series.length} series over ${n.value} periods (${props.labels[0]} to ${props.labels[last]}). Latest shares: ${shares.join(', ')}.`
})
let ro: ResizeObserver | undefined
onMounted(() => {
mounted.value = true
ro = new ResizeObserver(([entry]) => {
if (entry)
width.value = Math.max(260, Math.round(entry.contentRect.width))
})
ro.observe(root.value!)
morph()
})
onBeforeUnmount(() => {
ro?.disconnect()
tween?.stop()
})
</script>
<template>
<div :class="cn('w-full text-foreground', props.class)">
<div
ref="root"
tabindex="0"
class="relative rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
@keydown="onKey"
@blur="hoverIndex = null"
>
<svg
:width="width"
:height="props.height"
:viewBox="`0 0 ${width} ${props.height}`"
role="img"
:aria-label="summary"
class="block select-none overflow-visible"
>
<!-- grid + axes -->
<g class="text-[11px] tabular-nums">
<AnimatePresence :initial="false">
<motion.g
:key="yTicks.key"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="orInstant({ duration: 0.35 })"
>
<g v-for="t in yTicks.ticks" :key="t.v">
<line :x1="LEFT" :x2="LEFT + plotW" :y1="t.y" :y2="t.y" class="stroke-border" :stroke-dasharray="t.v === 0 ? undefined : '2 4'" />
<text :x="LEFT - 8" :y="t.y" dy="0.32em" text-anchor="end" class="fill-muted-foreground">{{ t.label }}</text>
</g>
</motion.g>
</AnimatePresence>
<text
v-for="t in xTicks"
:key="t.i"
:x="t.x"
:y="bottom + 17"
:text-anchor="t.i === 0 ? 'start' : t.i === n - 1 ? 'end' : 'middle'"
class="fill-muted-foreground"
>{{ t.label }}</text>
</g>
<!-- bands -->
<g
v-for="l in layers"
:key="l.name"
class="pointer-events-none"
>
<path :d="l.area" :style="{ fill: l.color, fillOpacity: fillOpacity(l.name) }" class="transition-[fill-opacity] duration-200" />
<path
:d="l.edge"
fill="none"
:style="{ stroke: l.color }"
stroke-width="1.75"
stroke-linejoin="round"
:opacity="l.visible ? 1 : 0"
class="transition-opacity duration-300"
/>
</g>
<!-- crosshair -->
<g v-if="hoverIndex !== null" class="pointer-events-none">
<line :x1="x(hoverIndex)" :x2="x(hoverIndex)" :y1="TOP" :y2="bottom" class="stroke-muted-foreground" stroke-dasharray="3 3" />
<circle
v-for="d in dots"
:key="d.name"
:cx="x(hoverIndex)"
:cy="d.cy"
r="3.5"
class="stroke-background"
stroke-width="2"
:style="{ fill: d.color }"
/>
</g>
<rect
:x="LEFT"
:y="TOP"
:width="plotW"
:height="bottom - TOP"
fill="transparent"
class="cursor-crosshair"
@pointermove="onMove"
@pointerleave="hoverIndex = null"
/>
</svg>
<AnimatePresence>
<motion.div
v-if="tip && readout"
key="tip"
aria-hidden="true"
class="pointer-events-none absolute left-0 top-0 z-10"
:initial="{ opacity: 0, scale: 0.96, x: tip.x, y: tip.y }"
:animate="{ opacity: 1, scale: 1, x: tip.x, y: tip.y }"
:exit="{ opacity: 0, scale: 0.96, transition: { duration: 0.12 } }"
:transition="reduce ? { duration: 0 } : TIP"
>
<div
class="min-w-40 rounded-md border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-md"
:class="tip.flipX && '-translate-x-full'"
>
<div class="mb-1.5 font-medium">
{{ readout.label }}
</div>
<div v-for="r in readout.rows" :key="r.name" class="flex items-center gap-2 py-0.5 tabular-nums">
<span class="size-2 shrink-0 rounded-sm" :style="{ background: r.color }" />
<span class="mr-auto text-muted-foreground">{{ r.name }}</span>
<span class="font-medium">{{ fmtValue(r.value) }}</span>
<span class="w-10 text-right text-muted-foreground">{{ fmtPct(r.share) }}</span>
</div>
<div class="mt-1 flex justify-between border-t pt-1 tabular-nums">
<span class="text-muted-foreground">Total</span>
<span class="font-semibold">{{ fmtValue(readout.total) }}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
<div v-if="props.legend" class="mt-3 flex flex-wrap items-center justify-center gap-1" role="group" aria-label="Series">
<button
v-for="(s, k) in props.series"
:key="s.name"
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs outline-none transition-[color,background-color,opacity] hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
:class="hidden.includes(s.name) ? 'text-muted-foreground opacity-60' : 'text-foreground'"
:aria-pressed="!hidden.includes(s.name)"
:title="`Click to isolate ${s.name} · Shift-click to toggle`"
@click="onLegend(s.name, $event)"
@pointerenter="focusSeries = hidden.includes(s.name) ? null : s.name"
@pointerleave="focusSeries = null"
@focus="focusSeries = hidden.includes(s.name) ? null : s.name"
@blur="focusSeries = null"
>
<span
class="size-2.5 rounded-sm transition-transform"
:class="hidden.includes(s.name) && 'scale-75'"
:style="{ background: hidden.includes(s.name) ? 'transparent' : colorOf(s, k), boxShadow: `inset 0 0 0 1.5px ${colorOf(s, k)}` }"
/>
{{ s.name }}
</button>
</div>
</div>
</template>export interface StackedSeries {
name: string
values: number[]
/** Any CSS color. Defaults to cycling through `--chart-1…5`. */
color?: string
}
/** One series' band: lower and upper edge per period, in value space. */
export interface Band {
y0: number[]
y1: number[]
}
/**
* Stacks the series bottom-up. Hidden series collapse to zero thickness (so they can animate
* away instead of popping); in `percent` mode every period is normalised to 1.
*/
export function stack(series: StackedSeries[], length: number, hidden: Set<string>, percent: boolean) {
const totals = Array.from({ length }, (_, i) =>
series.reduce((s, x) => s + (hidden.has(x.name) ? 0 : Math.max(0, x.values[i] ?? 0)), 0))
const base = Array.from({ length }, () => 0)
const bands: Band[] = series.map((x) => {
const y0 = [...base]
const y1 = y0.map((b, i) => {
const v = hidden.has(x.name) ? 0 : Math.max(0, x.values[i] ?? 0)
return b + (percent ? (totals[i] ? v / totals[i]! : 0) : v)
})
y1.forEach((v, i) => (base[i] = v))
return { y0, y1 }
})
return { bands, totals, max: percent ? 1 : Math.max(0, ...totals) }
}
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
/** "Nice" round ticks from 0 to at least `max`. */
export function niceTicks(max: number, count = 4) {
if (!(max > 0))
return { ticks: [0], top: 1 }
const raw = max / count
const pow = 10 ** Math.floor(Math.log10(raw))
const r = raw / pow
const step = (r > 5 ? 10 : r > 2.5 ? 5 : r > 2 ? 2.5 : r > 1 ? 2 : 1) * pow
const top = Math.ceil(max / step - 1e-9) * step
const ticks: number[] = []
for (let v = 0; v <= top + step * 1e-9; v += step)
ticks.push(Number(v.toFixed(10)))
return { ticks, top }
}
/** Monotone cubic (Fritsch–Carlson) segments through `p`, without the leading move. */
function monotoneSegments(p: [number, number][]) {
const n = p.length
const m: number[] = []
for (let i = 0; i < n - 1; i++)
m[i] = (p[i + 1]![1] - p[i]![1]) / (p[i + 1]![0] - p[i]![0] || 1)
const t: number[] = [m[0] ?? 0]
for (let i = 1; i < n - 1; i++)
t[i] = m[i - 1]! * m[i]! <= 0 ? 0 : (m[i - 1]! + m[i]!) / 2
t[n - 1] = m[n - 2] ?? 0
let d = ''
for (let i = 0; i < n - 1; i++) {
const h = (p[i + 1]![0] - p[i]![0]) / 3
d += ` C${p[i]![0] + h},${p[i]![1] + t[i]! * h} ${p[i + 1]![0] - h},${p[i + 1]![1] - t[i + 1]! * h} ${p[i + 1]![0]},${p[i + 1]![1]}`
}
return d
}
function segments(p: [number, number][], smooth: boolean) {
return smooth ? monotoneSegments(p) : p.slice(1).map(([x, y]) => ` L${x},${y}`).join('')
}
/** Upper edge only. */
export function edgePath(top: [number, number][], smooth: boolean) {
return top.length ? `M${top[0]![0]},${top[0]![1]}${segments(top, smooth)}` : ''
}
/** Closed band: along the upper edge, back along the lower edge. */
export function bandPath(top: [number, number][], bottom: [number, number][], smooth: boolean) {
if (!top.length)
return ''
const back = [...bottom].reverse()
return `${edgePath(top, smooth)} L${back[0]![0]},${back[0]![1]}${segments(back, smooth)} Z`
}export type { StackedSeries } from './stack'
export { default as StackedArea } from './StackedArea.vue'Usage
import { StackedArea, type StackedSeries } from '@/components/ui/stacked-area'<StackedArea
v-model:mode="mode"
:labels="['Q1', 'Q2', 'Q3', 'Q4']"
:series="[
{ name: 'Hardware', values: [120, 132, 128, 150] },
{ name: 'Software', values: [80, 96, 118, 140] },
{ name: 'Services', values: [40, 42, 47, 51] },
]"
/>Everything that changes the stack — switching mode, hiding a series, new series data — morphs the bands from where they are to where they need to be, axis included. Hidden series collapse to zero thickness instead of disappearing.
The legend isolates on click (click the isolated series again to bring everything back) and toggles a single series on Shift-click. Hovering a legend item highlights its band. With the chart focused, ← / → move the crosshair.
API
| Prop | Type | Default | Description |
|---|---|---|---|
labels | string[] | — | One label per period. |
series | StackedSeries[] | — | { name, values, color? }, stacked bottom-up. color is any CSS color; defaults cycle --chart-1…5. |
v-model:mode | 'stacked' | 'percent' | 'stacked' | Absolute values or 100% stacked. |
v-model:hidden | string[] | [] | Names of hidden series. |
height | number | 300 | Chart height in px (legend excluded). |
smooth | boolean | true | Monotone curves. |
legend | boolean | true | Interactive legend under the chart. |
locale | string | 'en-US' | Used by the default formatter. |
formatValue | (v) => string | compact number | Axis and tooltip values. |