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
- Referral
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.
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.
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.
- Direct
- Search
- Social
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>