Svelte 5 UI and charting components. Charts are built on LayerCake with runes-based state stores; interactive components are built on bits-ui headless primitives; everything is themed with CSS custom properties — no Tailwind, no runtime styling dependencies.
pnpm add @chienleng/stratum-uiImport a theme once in your root layout, then use components:
<script>
import '@chienleng/stratum-ui/themes/theme-1.css'; // or themes/neutral.css / themes/theme-2.css
import { Button } from '@chienleng/stratum-ui/ui';
import { ChartStore, StratumChart } from '@chienleng/stratum-ui/charts';
</script>| Import | Contents |
|---|---|
@chienleng/stratum-ui |
Theme helpers (breakpoints, seriesVar, resolveToken, tokenGroups), palette resolvers, fuel-tech colour maps |
@chienleng/stratum-ui/charts |
ChartStore + option/style/tooltip stores, StratumChart, StackedAreaChart, BarChart, GroupedBarChart, MiniCharts, DateBrush, tooltips, header/controls, presets, sync helpers, interval utilities |
@chienleng/stratum-ui/charts/elements |
The LayerCake SVG elements (axes, marks, overlays, InteractionLayer) for composing custom charts |
@chienleng/stratum-ui/ui |
Button, Card family, Tooltip, Modal, Sheet, BottomSheet, OptionsMenu, Switch family, GridLayout, Skeleton, Toaster + createToastStore, … |
@chienleng/stratum-ui/forms |
Checkbox, CheckboxTree, Radio, Select, MultiSelect, TextInput, Textarea, DateField, CurrencyInput, Toggle, RangeSelector |
@chienleng/stratum-ui/actions |
portal, dropdownPosition, clickoutside |
@chienleng/stratum-ui/utils |
SI-unit conversion, number/date formatting (incl. parseCurrency), data transforms |
@chienleng/stratum-ui/grid |
DataGrid (virtualised rows and columns, pinned columns, sorting, selection, resizing, footer), column/sort types, sortedIndices, defaultCompare |
@chienleng/stratum-ui/map |
PointMap (MapLibre GL bubble map), MapLegend, DaylightLayer, CloudCoverLayer, collapseMapAttribution, DEFAULT_MAP_STYLES, isLightMapTheme — requires the optional peers svelte-maplibre-gl + maplibre-gl |
@chienleng/stratum-ui/themes/* |
Theme CSS files (see below) |
@chienleng/stratum-ui/icons/*.svelte |
Vendored icon components |
Every component is styled exclusively against --su-* custom properties.
Three self-contained theme files ship with the package:
@chienleng/stratum-ui/themes/theme-1.css— warm greys, DM Sans/Space Grotesk/DM Mono stacks, OE red accent@chienleng/stratum-ui/themes/neutral.css— system fonts, neutral greys@chienleng/stratum-ui/themes/theme-2.css— slate neutrals, Inter/JetBrains Mono, dark primary actions with a blue data accent, square corners@chienleng/stratum-ui/themes/fuel-techs.css— optional fuel-technology palette (--su-ft-*), mirrored in JS asfuelTechColours
Each file declares tokens under :where(:root), [data-theme='<name>']:
- One theme: import it and you're done — the zero-specificity
:rootarm applies globally. - Runtime switching: import both and set
document.documentElement.dataset.theme = 'neutral'— the attribute arm always wins. - Copy-paste: each file is self-contained (custom-property declarations
plus one
color-scheme, no resets) — paste one into any project and edit values freely. - Brand overrides: consuming apps can re-theme by declaring a
higher-specificity override sheet (e.g.
:root[data-brand='x'] { --su-accent: … }) that redefines only the diverging tokens — no fork or extra theme file needed.
Since 0.12.0 every theme ships both modes. data-mode on the root element
selects which:
data-mode |
Result |
|---|---|
| absent | follow the operating system |
'light' |
force light, whatever the OS says |
'dark' |
force dark, whatever the OS says |
document.documentElement.dataset.mode = 'dark'; // force
delete document.documentElement.dataset.mode; // back to following the OSNote that "follow the system" is the absence of the attribute, not a third
value — that is what lets the OS preference reach light-dark().
Every colour token is declared once, as light-dark(light, dark), so there is
no parallel block to keep in step and no token can be left behind at its light
value; a unit test asserts this for all three themes. color-scheme is the one
non-custom-property declaration in a theme file. It has to be there: it is what
light-dark() reads, and without it the native furniture — scrollbars, form
controls, the canvas behind the page — stays light while everything else goes
dark.
Dark is not a mechanical inversion. The numbered status and data ramps encode
roles rather than lightness — 50/100 is the tinted background, 200 the border,
500/600 the solid fill, 700/800 the strong text on that tint — so preserving
the role makes 50 the darkest step in dark mode and 800 the lightest. Theme 1
also lifts the OE red for dark (#c74523 scores only 2.4:1 on the dark ground)
and flips --su-accent-contrast to dark text, because white on the lifted red
does not clear AA.
Contrast is asserted, not eyeballed: the suite resolves every token in both modes and holds text pairings to WCAG AA, with a further rule that dark may never score worse than light for the softer roles.
Since 0.8.0 the semantic set also covers app-chrome needs beyond the component
API: extra neutral steps (--su-surface-emphasis, --su-text-secondary,
--su-text-faint, --su-border-muted), numbered status ramps
(--su-danger-*, --su-warning-*, --su-success-* — the unnumbered tokens
remain the component defaults) and a data-accent ramp (--su-data-accent-*,
distinct from --su-accent, the primary-action colour).
Unthemed usage degrades gracefully: every component carries Neutral-value
fallbacks in its var() references.
Per-component knobs exist sparingly (e.g. --su-button-bg); series colours
flow through charts as plain strings, so var(--su-chart-series-3) and hex
values both work — theme switches recolour live charts with no JS.
The library never bundles fonts — theme files reference family names with system fallbacks. For the full Theme 1 (OE) look:
pnpm add -D @fontsource-variable/dm-sans @fontsource-variable/space-grotesk @fontsource/dm-mono// root layout
import '@fontsource-variable/dm-sans';
import '@fontsource-variable/space-grotesk';
import '@fontsource/dm-mono/400.css';
import '@fontsource/dm-mono/500.css';For the Theme 2 (Furrow) look:
pnpm add -D @fontsource-variable/inter @fontsource-variable/jetbrains-mono<script lang="ts">
import { ChartStore, StratumChart } from '@chienleng/stratum-ui/charts';
const chart = new ChartStore({
key: Symbol('demo'),
title: 'Generation',
prefix: 'M',
baseUnit: 'W'
});
chart.seriesData = rows; // [{ time, date, coal: 123, wind: 456, … }]
chart.seriesNames = ['coal', 'wind'];
chart.seriesColours = { coal: 'var(--su-chart-series-11)', wind: 'var(--su-chart-series-4)' };
chart.xDomain = [rows[0].time, rows.at(-1)!.time];
</script>
<StratumChart {chart} showHeader enablePan resizable />Bar charts highlight the hovered column with a band and report hover for the
whole column, not only the bar under the pointer. The floating tooltip places
itself beside the hovered column; pass tooltipAnchor="top" to keep it at the
top of the chart and let it move only sideways, rather than snapping between
the chart's halves as the pointer moves.
Line charts can name the line under the pointer: set
chart.chartStyles.lineHitWidth (px, default 0) and the line nearest the
pointer at the hovered time, within half that width, reports its series key
through onhover(time, key) and chart.hoverKey, as stacked areas do. It is
computed from the drawn values over one hit area, so lines add no extra
pointer geometry. Pair it with
chart.chartOptions.allowHoverHighlight to recede the other lines.
The showcase site (pnpm dev) demonstrates every component, including
tooltip modes, pan/zoom, brushing and the live token sheet at
/theme/tokens (with copy-theme-CSS buttons).
Select renders a select-only combobox. Give it a visible label using Field
or <label for> and the matching id. Use label to name the popup as well as
provide its empty-state text. aria-label and aria-labelledby are available
when an explicit accessible name is needed; aria-labelledby takes precedence.
<label for="record">Project record</label>
<Select
id="record"
label="Project record"
selected={record}
options={records}
onchange={(value) => (record = value)}
portalTarget="main"
/>portalTarget accepts an element or selector and defaults to document.body.
Choose a container within the relevant landmark or dialog when the popup should
remain in that reading context or inherit a scoped theme. The container must
exist when the popup mounts and allow the floating content to remain visible.
Keyboard focus stays on the trigger while aria-activedescendant identifies the
highlighted option; aria-controls references the open listbox. Popup motion
preserves full text opacity throughout its animation.
DataGrid renders only the rows and columns in view, inside one native scroll
container, so it stays fast with hundreds of thousands of rows and thousands of
columns. The header, footer and pinned columns are CSS sticky.
<script lang="ts">
import { DataGrid, type GridColumn, type GridSort } from '@chienleng/stratum-ui/grid';
// $state.raw: large arrays must not be deeply reactive.
let rows = $state.raw<Unit[]>(data.units);
let sort = $state<GridSort | null>(null);
let selected = $state<string[]>([]);
const columns: GridColumn<Unit>[] = [
{ id: 'name', header: 'Name', width: 200 },
{ id: 'region', header: 'Region', flexgrow: 1 },
{
id: 'mw',
header: 'Capacity',
align: 'end',
format: (v) => `${v} MW`,
footer: (all) => `${all.reduce((sum, u) => sum + u.mw, 0)} MW`
}
];
</script>
<div style="height: 480px">
<DataGrid
data={rows}
{columns}
rowKey="code"
pinned={{ left: 1 }}
selectionMode="multiple"
bind:selected
bind:sort
storageKey="units-grid-widths"
/>
</div>- The grid fills its container's height, so give the container a height.
- Columns:
id(also the field read, unlessvalue(row)is given),header,width(default 160),minWidth/maxWidth,flexgrow(shares spare width),align,format(value, row), acellsnippet receiving{ row, value, column, rowIndex },footer(text or a function of all rows) or afooterCellsnippet, andsortable/resizable/compareopt-outs. - Sorting: clicking a header cycles ascending → descending → off. By
default the grid sorts a view of the rows and never mutates
data. Blank values sort last, and text sorts naturally ("Unit 2" before "Unit 10"). UsesortMode="manual"to sort the data yourself, e.g. on a server. - Selection:
selectionModeis'single'or'multiple'. Click, Ctrl/Cmd+click and Shift+click work, as do Space, and Ctrl/Cmd+A when multiple.selectedholds row keys fromrowKey, which defaults toid; keys must be unique. - Keyboard: the grid is one tab stop with ARIA grid semantics.
- Arrows, Home/End, Ctrl/Cmd+Home/End and PageUp/PageDown move focus.
- Enter or Space on a header sorts.
- Enter or double-click on a row calls
onrowactivate. - Alt+←/→ on a header resizes the column.
- Resizing: drag a header's right edge.
columnWidthsis bindable, andstorageKeyremembers widths inlocalStorage. - Limits: rows have a fixed height (
rowHeight, default 36). Browsers cap an element's height at roughly 16–33 million px, so around 400,000 rows at 36 px is the practical ceiling, with Firefox the lowest.
The ./map entry point is the only one with peer dependencies beyond Svelte —
install them alongside the package:
pnpm add -D svelte-maplibre-gl maplibre-gl<script lang="ts">
import { PointMap, type MapPoint } from '@chienleng/stratum-ui/map';
const points: MapPoint[] = [
{ id: 'syd', lng: 151.2, lat: -33.87, label: 'Sydney', colour: '#4e79a7', radius: 8, raw: {} }
];
</script>
<PointMap {points} mapTheme="light" onclick={(point) => console.log(point)} />Notes:
- Base styles come from
DEFAULT_MAP_STYLES(CARTO positron/voyager/dark-matter CDN styles plus an Esri World Imagery raster style forsatellite). Override any of them per app via themapStylesprop — e.g. self-hosted style JSON and glyphs:mapStyles={{ light: '/map-styles/positron.json' }}.isLightMapThemeanswers "is this basemap dark?" consistently (voyagergroups withlight). DaylightLayer(day/night terminator with graduated twilight bands, computed locally — no network requests) andCloudCoverLayer(near-real-time NASA GIBS · JMA Himawari infrared cloud imagery, no API key, ~10-minute cadence, Asia-Pacific coverage only — blank beyond the Himawari full disc) compose intoPointMap'schildrensnippet. Both takevisible,mapTheme(picks a shade/treatment that reads on that basemap) andbeforeId(pass"point-map-circles"to render beneath PointMap's markers). Credit NASA GIBS when using the cloud layer; the source's attribution string flows into the map's attribution control automatically.- On
maplibre-glv6 (ESM-only) the tile worker is a separate module that bundlers do not pick up on their own. Under Vite, side-effect importsvelte-maplibre-gl/viteonce in the route/component that renders the map — without it the worker 404s in a production build. That entry point also needsoptimizeDeps: { exclude: ['svelte-maplibre-gl'] }in your Vite config — otherwise the dep pre-bundler chokes on its?worker&urlimport andvite devfails to start. svelte-maplibre-glinjectsmaplibre-gl.cssfrom CDN at runtime by default. PassautoloadGlobalCss={false}andimport 'maplibre-gl/dist/maplibre-gl.css'yourself to avoid the fetch.- Point colours are MapLibre paint values, so they must be resolved colours
(hex/rgb), not
var()references — usefuelTechColoursor your own hexes. - Extra
svelte-maplibre-gllayers/controls compose in via thechildrensnippet, and the map instance is exposed as a bindablemapprop.
pnpm install
pnpm dev # showcase site
pnpm test # vitest (280+ unit tests)
pnpm check # svelte-check
pnpm lint # prettier + eslint
pnpm package # build dist/ + publintBaseline 2023+ — the styling relies on color-mix() and :where().
MIT. Icon path data vendored from Lucide (ISC).