Github

Chart

<figure class="chart"> with a <figcaption> and an accessible name. Three types: bar, line, and area. Curve is a path option on line and area, not a fourth type. A three-series area is still area — three fills on one plot. Colors are --chart-1 through --chart-5. There is no wrapper.

Each type has a static HTML paste (no data binding) and a complete dynamic example: a JavaScript array / $state plus the HTML that consumes it. Copy src/components/ui/chart/scale.js, src/components/ui/chart/hover.js, src/components/ui/chart/path.js, and src/components/ui/chart/ticks.js. Alumna does not compile relative .js — paste those functions into a <script module> file, or copy the matching scale.svelte, hover.svelte, path.svelte, and ticks.svelte (same API). This page imports the .svelte files. Snippets name the helpers; they do not paste their source.

Bar

Five series so every token shows. The live figure is bound to $state data — switch weeks and the bars update. Hover or focus a bar for the value. barPercents writes --chart-size; tipText fills .chart-tip (native title is not enough).

  • Direct
  • Search
  • Social
  • Email
  • Referral
Visits by channel, last 7 days

Direct 72 · Search 48 · Social 90 · Email 36 · Referral 64

Static

Hardcoded --chart-size percents. No {#each} — paste the markup and wire your own logic.

<figure class="chart" aria-label="Visits by channel">
	<div class="chart-bars">
		<button class="chart-bar" type="button" style="--chart-size: 80%" aria-label="Direct: 72">
			<span class="chart-tip" aria-hidden="true">Direct: 72</span>
		</button>
		<button class="chart-bar" type="button" style="--chart-size: 53.33%" aria-label="Search: 48">
			<span class="chart-tip" aria-hidden="true">Search: 48</span>
		</button>
		<button class="chart-bar" type="button" style="--chart-size: 100%" aria-label="Social: 90">
			<span class="chart-tip" aria-hidden="true">Social: 90</span>
		</button>
		<button class="chart-bar" type="button" style="--chart-size: 40%" aria-label="Email: 36">
			<span class="chart-tip" aria-hidden="true">Email: 36</span>
		</button>
		<button class="chart-bar" type="button" style="--chart-size: 71.11%" aria-label="Referral: 64">
			<span class="chart-tip" aria-hidden="true">Referral: 64</span>
		</button>
	</div>
	<ul class="chart-key">
		<li>Direct</li>
		<li>Search</li>
		<li>Social</li>
		<li>Email</li>
		<li>Referral</li>
	</ul>
	<figcaption>Visits by channel, last 7 days</figcaption>
</figure>

Dynamic

The live figure above is this $state array. Pass it into barPercents. Default domain is 0 to the series max (Social at 90 becomes 100% height). Optional token sets background: var(--chart-N).

<script>
	import { barPercents, barStyle } from './scale.js';
	import { tipText } from './hover.js';

	let series = $state([
		{ label: 'Direct', value: 72 },
		{ label: 'Search', value: 48 },
		{ label: 'Social', value: 90 },
		{ label: 'Email', value: 36 },
		{ label: 'Referral', value: 64 }
	]);
	const sized = $derived(barPercents(series));
	// Optional token paints that bar with --chart-N and skips nth-child:
	// { label: 'Direct', value: 72, token: '1' }
	// Values already 0–100: barPercents(series, [0, 100])
</script>

<figure class="chart" aria-label="Visits by channel">
	<div class="chart-bars">
		{#each sized as item (item.label)}
			<button class="chart-bar" type="button" style={barStyle(item)} aria-label={tipText(item)}>
				<span class="chart-tip" aria-hidden="true">{tipText(item)}</span>
			</button>
		{/each}
	</div>
	<ul class="chart-key">
		{#each sized as item (item.label)}
			<li>{item.label}</li>
		{/each}
	</ul>
	<figcaption>Visits by channel, last 7 days</figcaption>
</figure>

Line

Rebuild the polyline in pancake data space: viewBox="0 0 100 100" preserveAspectRatio="none" on .chart-line inside .chart-plot. plotSeries maps { label, value } into that space; linePath writes d (M/L, or Catmull-Rom cubics when curve is true). Stroke is --chart-series (defaults to --chart-3) with vector-effect: non-scaling-stroke. HTML .chart-mark dots do not squash. Hover snaps to the nearest point by x.

Sessions, Monday through Sunday

Mon 18 · Tue 32 · Wed 24 · Thu 41 · Fri 28 · Sat 19 · Sun 12

Static

Hardcoded path d and mark insets. Plot frame is .chart-plot. No data binding.

<figure class="chart" aria-label="Weekly sessions">
	<div class="chart-plot">
		<svg class="chart-line" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<path class="chart-line-path" d="M0,64L16.67,36L33.33,52L50,18L66.67,44L83.33,62L100,76"></path>
		</svg>
		<div class="chart-marks">
			<span class="chart-mark" style="inset-inline-start: 0%; inset-block-start: 64%"></span>
			<span class="chart-mark" style="inset-inline-start: 16.67%; inset-block-start: 36%"></span>
			<span class="chart-mark" style="inset-inline-start: 33.33%; inset-block-start: 52%"></span>
			<span class="chart-mark" style="inset-inline-start: 50%; inset-block-start: 18%"></span>
			<span class="chart-mark" style="inset-inline-start: 66.67%; inset-block-start: 44%"></span>
			<span class="chart-mark" style="inset-inline-start: 83.33%; inset-block-start: 62%"></span>
			<span class="chart-mark" style="inset-inline-start: 100%; inset-block-start: 76%"></span>
		</div>
	</div>
	<figcaption>Sessions, Monday through Sunday</figcaption>
</figure>

Dynamic

The live figure above is this $state array. plotSeries projects it; linePath writes d (curve true → spline). hoverPoint is nearest-x, not a 2D search.

<script>
	import { plotSeries } from './scale.js';
	import { linePath } from './path.js';
	import { hoverPoint, tipText, plotStyle, cursorStyle } from './hover.js';

	let sessions = $state([
		{ label: 'Mon', value: 18 },
		{ label: 'Tue', value: 32 },
		{ label: 'Wed', value: 24 },
		{ label: 'Thu', value: 41 },
		{ label: 'Fri', value: 28 },
		{ label: 'Sat', value: 19 },
		{ label: 'Sun', value: 12 }
	]);
	let curve = $state(false);
	const points = $derived(plotSeries(sessions, { y: [0, 50] }));
	const d = $derived(linePath(points, curve));
	let hover = $state(null);

	function move(event) {
		hover = hoverPoint(points, event, event.currentTarget);
	}
	function leave() {
		hover = null;
	}
</script>

<figure class="chart" aria-label="Weekly sessions">
	<div class="chart-plot" role="group" data-markers="on" onmousemove={move} onmouseleave={leave}>
		<svg class="chart-line" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<path class="chart-line-path" d={d}></path>
		</svg>
		<div class="chart-marks">
			{#each points as item (item.label)}
				<span class="chart-mark" style={plotStyle(item)}></span>
			{/each}
		</div>
		{#if hover}
			<span class="chart-cursor" style={cursorStyle(hover)}></span>
			<span class="chart-spot" style={plotStyle(hover)}></span>
			<span class="chart-tip" aria-hidden="true" style={plotStyle(hover)}>{tipText(hover)}</span>
		{/if}
	</div>
	<figcaption>Sessions, Monday through Sunday</figcaption>
</figure>

Area

Same scales, path option, and nearest-x hover as the line. areaPath closes to a floor at y=100. Fill mixes --chart-series. This figure sets --chart-series: var(--chart-1). Optional axis titles are .chart-axis with data-axis="x" or "y". Grid lines use --chart-grid (defaults to --border). Tick numbers live in an HTML overlay so they do not stretch with the viewBox.

Sessions
01020304050
MonTueWedThuFriSatSun
Weekday
Sessions, Monday through Sunday

Mon 18 · Tue 32 · Wed 24 · Thu 41 · Fri 28 · Sat 19 · Sun 12

Static

Hardcoded fill, stroke, grid, and ticks. Same .chart-plot frame. No data binding.

<figure class="chart" aria-label="Weekly sessions" style="--chart-series: var(--chart-1)">
	<div class="chart-axis" data-axis="y">Sessions</div>
	<div class="chart-plot" data-markers="off">
		<svg class="chart-area" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<path class="chart-area-path" d="M0,64L16.67,36L33.33,52L50,18L66.67,44L83.33,62L100,76L100,100L0,100Z"></path>
			<path class="chart-line-path" d="M0,64L16.67,36L33.33,52L50,18L66.67,44L83.33,62L100,76"></path>
		</svg>
		<div class="chart-grid">
			<div class="chart-grid-line" style="inset-block-start: 80%"></div>
			<div class="chart-grid-line" style="inset-block-start: 60%"></div>
			<div class="chart-grid-line" style="inset-block-start: 40%"></div>
			<div class="chart-grid-line" style="inset-block-start: 20%"></div>
		</div>
		<div class="chart-ticks" data-axis="y">
			<span class="chart-tick" style="inset-block-start: 100%">0</span>
			<span class="chart-tick" style="inset-block-start: 80%">10</span>
			<span class="chart-tick" style="inset-block-start: 60%">20</span>
			<span class="chart-tick" style="inset-block-start: 40%">30</span>
			<span class="chart-tick" style="inset-block-start: 20%">40</span>
			<span class="chart-tick" style="inset-block-start: 0%">50</span>
		</div>
	</div>
	<div class="chart-ticks" data-axis="x">
		<span class="chart-tick" style="inset-inline-start: 0%">Mon</span>
		<span class="chart-tick" style="inset-inline-start: 16.67%">Tue</span>
		<span class="chart-tick" style="inset-inline-start: 33.33%">Wed</span>
		<span class="chart-tick" style="inset-inline-start: 50%">Thu</span>
		<span class="chart-tick" style="inset-inline-start: 66.67%">Fri</span>
		<span class="chart-tick" style="inset-inline-start: 83.33%">Sat</span>
		<span class="chart-tick" style="inset-inline-start: 100%">Sun</span>
	</div>
	<div class="chart-axis" data-axis="x">Weekday</div>
	<figcaption>Sessions, Monday through Sunday</figcaption>
</figure>

Dynamic

The live figure above is this $state array (same list as the line). areaPath closes the line; tickMarks feeds the HTML grid. --chart-series / --chart-grid / data-markers are CSS, not a chart library.

<script>
	import { plotSeries, linearScale } from './scale.js';
	import { linePath, areaPath } from './path.js';
	import { hoverPoint, tipText, plotStyle, cursorStyle } from './hover.js';
	import { tickMarks, tickStyle } from './ticks.js';

	let sessions = $state([
		{ label: 'Mon', value: 18 },
		{ label: 'Tue', value: 32 },
		{ label: 'Wed', value: 24 },
		{ label: 'Thu', value: 41 },
		{ label: 'Fri', value: 28 },
		{ label: 'Sat', value: 19 },
		{ label: 'Sun', value: 12 }
	]);
	let curve = $state(false);
	const points = $derived(plotSeries(sessions, { y: [0, 50] }));
	const line_d = $derived(linePath(points, curve));
	const fill = $derived(areaPath(points, curve));
	const y_ticks = $derived(tickMarks(0, 50, 5, linearScale([0, 50], [100, 0])));
	const y_grid = $derived(y_ticks.filter((tick) => tick.percent > 0 && tick.percent < 100));
	let hover = $state(null);

	function move(event) {
		hover = hoverPoint(points, event, event.currentTarget);
	}
	function leave() {
		hover = null;
	}
</script>

<figure class="chart" aria-label="Weekly sessions" style="--chart-series: var(--chart-1)">
	<div class="chart-axis" data-axis="y">Sessions</div>
	<div class="chart-plot" role="group" data-markers="off" onmousemove={move} onmouseleave={leave}>
		<svg class="chart-area" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<path class="chart-area-path" d={fill}></path>
			<path class="chart-line-path" d={line_d}></path>
		</svg>
		<div class="chart-grid">
			{#each y_grid as tick}
				<div class="chart-grid-line" style={tickStyle(tick.percent)}></div>
			{/each}
		</div>
		<div class="chart-ticks" data-axis="y">
			{#each y_ticks as tick}
				<span class="chart-tick" style={tickStyle(tick.percent)}>{tick.value}</span>
			{/each}
		</div>
		{#if hover}
			<span class="chart-cursor" style={cursorStyle(hover)}></span>
			<span class="chart-spot" style={plotStyle(hover)}></span>
			<span class="chart-tip" aria-hidden="true" style={plotStyle(hover)}>{tipText(hover)}</span>
		{/if}
	</div>
	<div class="chart-ticks" data-axis="x">
		{#each points as item (item.label)}
			<span class="chart-tick" style={tickStyle(item.x, 'x')}>{item.label}</span>
		{/each}
	</div>
	<div class="chart-axis" data-axis="x">Weekday</div>
	<figcaption>Sessions, Monday through Sunday</figcaption>
</figure>

Three-series area

Three areaPath series share one .chart-plot and the same y domain. Strokes are mint, apricot, and periwinkle (--chart-1 through --chart-3 on this figure). Each fill is that stroke at lower opacity so overlaps stay readable. Direct uses an SVG <linearGradient> of the same mint, more transparent toward the floor. Set --chart-fill on a group or path when the default color-mix is not enough. Curve is the same path option as the area example, on mix_curve so it does not move the line and area figures. Hover snaps to a weekday and lists Direct, Search, and Social.

01020304050
MonTueWedThuFriSatSun
  • Direct
  • Search
  • Social
Visits by channel, Monday through Sunday

Direct 22 · 30 · 26 · 38 · 34 · 28 · 18 · Search 14 · 20 · 32 · 24 · 40 · 36 · 22 · Social 8 · 16 · 12 · 28 · 18 · 30 · 14

Static

Hardcoded paths, pastel tokens, and one gradient id. No {#each}. Plot frame is .chart-plot.

<figure class="chart" aria-label="Weekly visits by channel" style="--chart-1: oklch(0.74 0.10 168); --chart-2: oklch(0.78 0.09 55); --chart-3: oklch(0.72 0.10 278)">
	<div class="chart-plot">
		<svg class="chart-area" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<defs>
				<linearGradient id="chart-fill-direct" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="100">
					<stop offset="0%" stop-color="oklch(0.74 0.10 168)" stop-opacity="0.45"></stop>
					<stop offset="100%" stop-color="oklch(0.74 0.10 168)" stop-opacity="0.06"></stop>
				</linearGradient>
			</defs>
			<g style="--chart-series: var(--chart-1); --chart-fill: url(#chart-fill-direct)">
				<path class="chart-area-path" d="M0,56L16.67,40L33.33,48L50,24L66.67,32L83.33,44L100,64L100,100L0,100Z"></path>
				<path class="chart-line-path" d="M0,56L16.67,40L33.33,48L50,24L66.67,32L83.33,44L100,64"></path>
			</g>
			<g style="--chart-series: var(--chart-2); --chart-fill: color-mix(in oklab, var(--chart-2) 30%, transparent)">
				<path class="chart-area-path" d="M0,72L16.67,60L33.33,36L50,52L66.67,20L83.33,28L100,56L100,100L0,100Z"></path>
				<path class="chart-line-path" d="M0,72L16.67,60L33.33,36L50,52L66.67,20L83.33,28L100,56"></path>
			</g>
			<g style="--chart-series: var(--chart-3); --chart-fill: color-mix(in oklab, var(--chart-3) 30%, transparent)">
				<path class="chart-area-path" d="M0,84L16.67,68L33.33,76L50,44L66.67,64L83.33,40L100,72L100,100L0,100Z"></path>
				<path class="chart-line-path" d="M0,84L16.67,68L33.33,76L50,44L66.67,64L83.33,40L100,72"></path>
			</g>
		</svg>
		<div class="chart-grid">
			<div class="chart-grid-line" style="inset-block-start: 80%"></div>
			<div class="chart-grid-line" style="inset-block-start: 60%"></div>
			<div class="chart-grid-line" style="inset-block-start: 40%"></div>
			<div class="chart-grid-line" style="inset-block-start: 20%"></div>
		</div>
		<div class="chart-ticks" data-axis="y">
			<span class="chart-tick" style="inset-block-start: 100%">0</span>
			<span class="chart-tick" style="inset-block-start: 80%">10</span>
			<span class="chart-tick" style="inset-block-start: 60%">20</span>
			<span class="chart-tick" style="inset-block-start: 40%">30</span>
			<span class="chart-tick" style="inset-block-start: 20%">40</span>
			<span class="chart-tick" style="inset-block-start: 0%">50</span>
		</div>
	</div>
	<div class="chart-ticks" data-axis="x">
		<span class="chart-tick" style="inset-inline-start: 0%">Mon</span>
		<span class="chart-tick" style="inset-inline-start: 16.67%">Tue</span>
		<span class="chart-tick" style="inset-inline-start: 33.33%">Wed</span>
		<span class="chart-tick" style="inset-inline-start: 50%">Thu</span>
		<span class="chart-tick" style="inset-inline-start: 66.67%">Fri</span>
		<span class="chart-tick" style="inset-inline-start: 83.33%">Sat</span>
		<span class="chart-tick" style="inset-inline-start: 100%">Sun</span>
	</div>
	<ul class="chart-key">
		<li>Direct</li>
		<li>Search</li>
		<li>Social</li>
	</ul>
	<figcaption>Visits by channel, Monday through Sunday</figcaption>
</figure>

Dynamic

The live figure above is these three $state arrays. Same y domain on every plotSeries call so the series share a scale. mix_curve feeds every areaPath / linePath. mix_tip gates nearest-x hover (shared weekday across Direct, Search, and Social). --chart-fill paints the gradient on Direct and a translucent mix on the others.

<script>
	import { plotSeries, linearScale } from './scale.js';
	import { linePath, areaPath } from './path.js';
	import { hoverPoint, plotStyle, cursorStyle } from './hover.js';
	import { tickMarks, tickStyle } from './ticks.js';

	const y = [0, 50];
	let mix_curve = $state(false);
	let mix_tip = $state(true);
	let direct = $state([
		{ label: 'Mon', value: 22 },
		{ label: 'Tue', value: 30 },
		{ label: 'Wed', value: 26 },
		{ label: 'Thu', value: 38 },
		{ label: 'Fri', value: 34 },
		{ label: 'Sat', value: 28 },
		{ label: 'Sun', value: 18 }
	]);
	let search = $state([
		{ label: 'Mon', value: 14 },
		{ label: 'Tue', value: 20 },
		{ label: 'Wed', value: 32 },
		{ label: 'Thu', value: 24 },
		{ label: 'Fri', value: 40 },
		{ label: 'Sat', value: 36 },
		{ label: 'Sun', value: 22 }
	]);
	let social = $state([
		{ label: 'Mon', value: 8 },
		{ label: 'Tue', value: 16 },
		{ label: 'Wed', value: 12 },
		{ label: 'Thu', value: 28 },
		{ label: 'Fri', value: 18 },
		{ label: 'Sat', value: 30 },
		{ label: 'Sun', value: 14 }
	]);
	const direct_pts = $derived(plotSeries(direct, { y }));
	const search_pts = $derived(plotSeries(search, { y }));
	const social_pts = $derived(plotSeries(social, { y }));
	const direct_line = $derived(linePath(direct_pts, mix_curve));
	const direct_fill = $derived(areaPath(direct_pts, mix_curve));
	const search_line = $derived(linePath(search_pts, mix_curve));
	const search_fill = $derived(areaPath(search_pts, mix_curve));
	const social_line = $derived(linePath(social_pts, mix_curve));
	const social_fill = $derived(areaPath(social_pts, mix_curve));
	const y_ticks = $derived(tickMarks(y[0], y[1], 5, linearScale(y, [100, 0])));
	const y_grid = $derived(y_ticks.filter((tick) => tick.percent > 0 && tick.percent < 100));
	let hover = $state(null);

	function toggle_mix_curve() {
		mix_curve = !mix_curve;
	}
	function toggle_mix_tip() {
		mix_tip = !mix_tip;
		if (!mix_tip)
			hover = null;
	}
	function move(event) {
		if (!mix_tip)
			return;
		hover = hoverPoint(direct_pts, event, event.currentTarget);
	}
	function leave() {
		hover = null;
	}
	function mixTipText(point) {
		if (!point)
			return '';
		let i = -1;
		for (let n = 0; n < direct_pts.length; n++) {
			if (direct_pts[n].label === point.label) {
				i = n;
				break;
			}
		}
		if (i < 0)
			return '';
		return 'Direct: ' + direct[i].value + ' · Search: ' + search[i].value + ' · Social: ' + social[i].value;
	}
</script>

<div class="cluster">
	<button class="button button-outline button-sm" type="button" onclick={toggle_mix_curve}>
		{mix_curve ? 'Straight segments' : 'Curve'}
	</button>
	<button class="button button-outline button-sm" type="button" onclick={toggle_mix_tip}>
		{mix_tip ? 'Hide tooltip' : 'Show tooltip'}
	</button>
</div>
<figure class="chart" aria-label="Weekly visits by channel" style="--chart-1: oklch(0.74 0.10 168); --chart-2: oklch(0.78 0.09 55); --chart-3: oklch(0.72 0.10 278)">
	<div class="chart-plot" role="group" onmousemove={move} onmouseleave={leave}>
		<svg class="chart-area" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
			<defs>
				<linearGradient id="chart-fill-direct" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="100">
					<stop offset="0%" stop-color="oklch(0.74 0.10 168)" stop-opacity="0.45"></stop>
					<stop offset="100%" stop-color="oklch(0.74 0.10 168)" stop-opacity="0.06"></stop>
				</linearGradient>
			</defs>
			<g style="--chart-series: var(--chart-1); --chart-fill: url(#chart-fill-direct)">
				<path class="chart-area-path" d={direct_fill}></path>
				<path class="chart-line-path" d={direct_line}></path>
			</g>
			<g style="--chart-series: var(--chart-2); --chart-fill: color-mix(in oklab, var(--chart-2) 30%, transparent)">
				<path class="chart-area-path" d={search_fill}></path>
				<path class="chart-line-path" d={search_line}></path>
			</g>
			<g style="--chart-series: var(--chart-3); --chart-fill: color-mix(in oklab, var(--chart-3) 30%, transparent)">
				<path class="chart-area-path" d={social_fill}></path>
				<path class="chart-line-path" d={social_line}></path>
			</g>
		</svg>
		<div class="chart-grid">
			{#each y_grid as tick}
				<div class="chart-grid-line" style={tickStyle(tick.percent)}></div>
			{/each}
		</div>
		<div class="chart-ticks" data-axis="y">
			{#each y_ticks as tick}
				<span class="chart-tick" style={tickStyle(tick.percent)}>{tick.value}</span>
			{/each}
		</div>
		{#if mix_tip && hover}
			<span class="chart-cursor" style={cursorStyle(hover)}></span>
			<span class="chart-spot" style={plotStyle(hover)}></span>
			<span class="chart-tip" aria-hidden="true" style={plotStyle(hover)}>{mixTipText(hover)}</span>
		{/if}
	</div>
	<div class="chart-ticks" data-axis="x">
		{#each direct_pts as item (item.label)}
			<span class="chart-tick" style={tickStyle(item.x, 'x')}>{item.label}</span>
		{/each}
	</div>
	<ul class="chart-key">
		<li>Direct</li>
		<li>Search</li>
		<li>Social</li>
	</ul>
	<figcaption>Visits by channel, Monday through Sunday</figcaption>
</figure>