Seven instruments.
One tag each.
A dial, a waveform, a heat ring, a radar scope, a level column, an
attitude indicator, and the layout that nests them. No framework, no
build step, no dependencies — paste one script tag and write
<cth-knob value="78">.
npm install cth-instrument
- 7elements
- 0dependencies
- 23.6 KBthe dial, gzipped
- 42attributes on it
Three lines to your first gauge
It is a standard custom element, so there is nothing to configure, register or wrap. The browser does the rest.
Load it
One module script. No bundler, no config file, no peer dependencies.
<!-- or ./src/cth-knob.js --> <script type="module" src="https://esm.sh/cth-instrument"> </script>
Write the tag
Attributes are the whole API. Change one and the dial re-renders itself.
<cth-knob value="78"></cth-knob>
Make it yours
Every colour and size is a CSS custom property. There is no options object.
cth-knob {
--cth-size: 180px;
--cth-value: #16a34a;
}
<cth-knob value="64" sweep="270" ticks="12" tick-major="3" zones="85-100:#ef4444" color="#7c3aed" label="CPU"></cth-knob>
The same tag in every framework
<cth-knob value="78">
<cth-knob value={v} />
<cth-knob :value="v" />
<cth-knob value={v} />
Small on purpose. Complete anyway.
Everything below ships inside the dial’s 23.6 KB. There is no pro tier, no plugin system, and nothing to install alongside it.
One file of standard JavaScript. Nothing to audit, nothing to keep updated.
sweep takes any angle — a 360° ring, a 270° gauge, a 180° half, or your own.
Custom properties and ::part(). Style it like any other element.
role="meter", live ARIA values, arrow keys, and reduced-motion aware.
Add interactive and it becomes a draggable, keyboard-operable slider.
Red-line bands, graduations and stacked slices without a chart library.
Both palettes are built in, and anything you set from outside wins.
Pure SVG — 40 px or 400 px, no blur, no retina hacks, no canvas.
Everything it can do
Every dial below is the same element with different attributes.
Basics
<cth-knob value="78">
color="#16a34a" label="Storage"
benchmark="70"
value="145" → overflow ring
Shapes — any arc via sweep
sweep="270"
sweep="180"
sweep="240" start="150"
--cth-thickness: 3
Readout & units
readout="value" unit="°C" decimals="1"
max="10000" readout="value"
readout="none" + slot="icon"
--cth-duration: 2s (click me)
Zones, ticks & stacked segments
zones + ticks + tick-major
needle + labels="N,E,S,W"
segments="42:… 23:… 14:… 9:…"
zones under a value ring
Same element, four sizes — SVG never blurs
VU & peak hold — a needle with ballistics
ballistics=".02 .5" peak-hold="1"
a classic VU face
the bar reads, the tick holds the peak
same signal, its own peak
the same numbers, unsmoothed
— every transient reads the same
Range & endless — two ways to take input
range="18 26" — drag either handle
arrows move the high one, shift+arrows the low
endless — no ends to hit.
Keep dragging; the count keeps going.
without interactive the same band
is a read-only display
<cth-trace> — the one reading a dial cannot give
beat="72" grid — a built-in ECG,
no script at all
shape="ring" — the same trace,
wrapped round the dial
trace.push(v) — feed it your own numbers
Life is full of dials
Temperature, brightness, a switch, a price, a signal, a light — none of these are charts. They are all the same ring, read differently.
trend — a price is two readings:
where it is and which way it went
a switch — toggle and two
icon slots is the whole of it
states — press to advance.
A light is not a low value of green.
six detents — the needle clicks
round one graduation a press
two whole faces, and turn="180"
swings the dial round as it changes
the same button, four faces —
states walks the year round
and five more — a face per
state, not a value per state
an electricity meter — gradient
maps the scale, not the value
mobile data against its cap —
benchmark is the allowance
a safety index — zones say
what the number means
brightness — drag it.
The same ring, now an input.
a water tank — the face is the vessel
Gas — density where a level cannot go
gas — liquid has a surface,
a gas only has density
the same dial, thicker air
the haze is an input too —
drag, or use the arrow keys
a leak building against its limit —
the band is the alarm, the delta the rate
Voice — the dial listening
press to listen — the ring is the level
off your own microphone, not a simulation
the same input as the dial beside it, drawn instead of measured:
.push(v) takes the samples straight off the analyser.
the same trace inside a dial, and the ring following its .level.
It talks to itself until the microphone is on, and then it is you.
silent
The dial as a record player
button toggle with two icon slots.
The ring is how far through the song you are.
spin="33" on a toggle button. It winds up
like a motor and coasts down like friction, and the track carries the song.
any middle can turn —
spin is revolutions per minute
Dials with something inside them
a <cth-trace> in slot="inset", and
pulse="auto" — the ring breathes at whatever the number says
no tube drawn inside the ring — the face
is the bore, and the fluid sits in it
<cth-heat> — a hundred values on one ring
24 hours round a clock face.
Hover a cell to read it.
rows="7" — a polar calendar:
seven days, twenty-four hours each
52 weeks, and the scale
fits itself to your data
<cth-heat shape="bars"> — a year you can read like a skyline
365 days round one ring
thirty-one of those days, at the height of the season
and the twelve totals they add up to
<cth-rings> — concentric knobs without the arithmetic
three knobs, sizes worked out for you
activity rings + a centre slot
thickness=".09" gap=".03"
each ring is still a whole knob
— here, three gradients
Composed — the ring as time, with no digits at all
three concentric knobs = a clock
hours / minutes / seconds, no numbers on the rings
indeterminate
sweep="70" + CSS rotate
a 10s countdown
driven by requestAnimationFrame
<cth-radar> — a filled field instead of a rim arc
Your system asks for reduced motion, so the sweep is parked rather than turning. Turn animation effects back on to watch it run.
period="4" — sweeping
no period — static range rings
themed with --cthr-* variables
rings="5" spokes="16"
Gradient — a colour ramp that follows the arc
gradient="#22c55e,#f59e0b,#ef4444"
the ramp bends round the whole circle
colour is the scale, not the value
— 35% only reaches the yellow
the value reveals the ramp
Liquid — for anything that really is a fluid
liquid — waves drift while it sits
themed with --cth-liquid
(click to pour)
liquid + zones + ticks
a nearly full tank
<cth-level> — the straight tube a ring cannot give you
liquid + ticks
zones tint the fluid sitting in them
bulb — a thermometer
no liquid — a plain column
(click to fill)
<cth-horizon> — attitude, hands, gyro, two pointers
pitch="0" roll="0" — wings level
pitch="12" roll="-25"
rotating — the card turns, the index stays
value-2 — a second pointer
needle="hand" + value-2
= a live clock face
a hand on a pressure gauge
tachometer — hand + red line
the default rim marker
Interactive — drag or use arrow keys
cth-change → —
min/max/step + sweep="270"
disabled
Driven by your own buttons
The element holds no state you cannot reach. Set value from a click
handler and the dial follows — that is the whole integration story for a timer,
a counter, a thermostat or a radar.
peak —
0 contacts
// there is no API to learn — it is just an attribute document.querySelector('[data-count="1"]').onclick = () => { knob.value = Math.min(20, knob.value + 1); };
The whole API
Forty-two attributes on <cth-knob>, twenty on
<cth-trace>, fifteen on <cth-heat>, thirteen on
<cth-level>, six on <cth-radar>, four on
<cth-horizon> and two on <cth-rings>. All seven
elements, and there is no second page.
| Attribute | Default | What it does |
|---|---|---|
value | min | Current value. Above max, an inner overflow ring appears. |
min / max | 0 / 100 | The scale. |
sweep | 360 | Degrees of arc. 360 is a ring, 270 a gauge, 180 a half. |
start | auto | Start angle. Defaults to the top, or a bottom-centred gap for a partial arc. |
benchmark | — | A target tick drawn on top of the value ring. |
zones | — | Coloured bands: "0-60:#22c55e, 85-100:#ef4444" |
segments | — | Stacked slices: "42:#3b82f6, 23:#8b5cf6" |
ticks / tick-major | — | Graduations, and every Nth one drawn heavier. |
readout | percent | percent, value or none. |
unit / decimals | % / 0 | Suffix and precision for the number. |
label | — | Caption under the number, and the accessible name. |
color | — | Shorthand for --cth-value. |
interactive / step | — / 1 | Turns it into a draggable, keyboard-operable slider. |
disabled | — | Dims it and ignores input. |
animate-in | — | Grow from empty on first paint. |
needle | — | A pointer that swings to the value, taking the short way round a closed dial. needle="hand" swaps the rim marker for a centre-mounted hand. |
labels | — | Upright captions spaced round the arc: "N,E,S,W". Every fourth reads heavier. |
label-radius | 29.5 | How far out the captions sit, in the 0–100 viewBox. |
value-2 | — | A second value, drawn as a second pointer alongside the first. |
rotating | — | The card turns under a fixed index, the way a heading indicator works. |
button | — | The dial becomes a button: focusable, Enter/Space activates it, and it fires cth-press. Add toggle to make it latch, pressed to read or set whether it is on, and slot="icon-on" for the pressed glyph. |
trend | — | A rise or a fall shown beside the number, with a drawn arrow and a sign colour. trend-unit suffixes it. |
states | — | A button that cycles: "stop:#ef4444, go:#22c55e". Each press advances, the lamp and ring take the colour, the caption the name. Read it back as .state. |
turn | 180 | The middle swings round to this angle and stays. On a toggle button it swings back when released. |
spin | 33 | The middle turns, in revolutions per minute. On a toggle button it only turns while pressed. Winds up like a motor, coasts down like friction. |
gas | — | Fills the face with drifting haze whose density is the value. Liquid has a surface; a gas only has how much of it there is. |
slot="inset" | — | Put a <cth-trace>, a <cth-level> or anything else inside the face. The knob lays it out; it stays an ordinary element you can script. |
inset | low | Where the inset sits: low for a chart under the number, fill for a tube up the middle. |
pulse | 60 | A ring that swells and fades at this many beats per minute, so the rhythm is legible from across the room. |
range | — | Two handles with a band between them: range="20 70". Reads back as {low, high}. |
endless | — | No ends: dragging reports movement, so the value counts past max while the ring wraps round. |
ballistics | — | Meter ballistics: "attack release" in seconds, one number for both. The reading snaps up and sags back instead of tracking the value. |
peak-hold | 1.2 | Seconds to hold the highest reading before it starts to fall. Draws a marker in --cth-peak. |
peak-fall | — | How fast the held peak decays, in value units per second. |
liquid | — | Fills the dial with fluid whose surface sits at the value, with drifting waves. Theme with --cth-liquid. |
gradient | — | A colour ramp that follows the arc: "#22c55e,#f59e0b,#ef4444". Colour maps to the scale, so the value reveals part of the ramp. |
<cth-trace>
| Attribute | Default | What it does |
|---|---|---|
shape | line | line for a strip, ring to wrap it round a circle. |
mode | sweep | sweep travels a pen over the old trace; scroll slides the window so the newest sample is always at the end. |
samples | 240 | How many samples the window holds. |
points | — | A written-out waveform: "12,40,38,90". No pen, nothing faded. |
beat | — | Beats per minute of a built-in ECG. Omit to feed it yourself. |
voice | — | A built-in talker instead: bursts of speech and real silences. Optionally 0–1 for how loud. |
mirror | — | The waveform straddles the centre line instead of rising from the floor. |
rate | 125 | Samples per second while self-driving — the paper speed. |
min / max | 0 / 100 | The vertical scale. |
grid | — | Graph paper behind the trace, ruled square or polar to match the shape. |
pen | — | pen="none" hides the writing head. |
readout-at | top left | Which corner the readout sits in. A resting trace sits low, so the bottom corner is the one place it must not go by default. |
sweep / start / amplitude | 360 / -90 / .18 | Ring shape only: the arc it covers and how far it deflects. |
readout | auto | The rate when beat is set, else the last sample. none to hide. |
unit / decimals / label / color | — | As on <cth-knob>. |
Feed it from script with .push(v); .clear(), .last, .level and .speaking read it back. Height comes from --cth-height and a strip takes its width from the box it sits in — on a trace --cth-width is the pen, not the frame. | ||
<cth-heat>
| Attribute | Default | What it does |
|---|---|---|
values | — | The list: "4,6,9,14,19". Also .values = [...] from script. |
shape | cells | cells for blocks of colour, bars for towers standing off a baseline. |
labels | — | Captions spaced round the ring: "Jan,Feb,…". |
label-radius | 49 / 47 | How far out the captions sit, in the 0–100 viewBox. |
scale | 5-stop blue→red | Colour stops the values are mapped across. |
min / max | the data | The colour domain. Omit to fit the data. |
rows | 1 | Split the list across this many concentric rings. |
sweep / start | 360 / -90 | The arc the cells cover. |
interactive | — | Hovering lifts a cell out of the ring and reads it in the middle. |
readout | auto | The hovered cell, else the ring’s average; sum for a total, max for the biggest, none to hide. |
unit / decimals / label | — | As on <cth-knob>. |
Length already carries the value, so bars defaults to one colour: pass scale when you want the ramp back. cth-hover fires with {index, value}, and index is -1 when the pointer leaves. | ||
<cth-radar>
| Attribute | Default | What it does |
|---|---|---|
rings | 4 | Concentric range rings. |
spokes | 8 | Bearing spokes. 0 for none. |
period | — | Seconds per sweep revolution. Omit or 0 for a static scope. |
labels | — | Bearing captions, spaced evenly from north. |
blips | — | Contacts as bearing:range pairs: "45:0.6, 210:0.35". |
interactive | — | Clicking the scope adds a contact where you clicked. |
Under prefers-reduced-motion the sweep parks rather than turning — it stays visible, it just holds still. | ||
<cth-rings>
Lays out; does not draw. The children stay ordinary knobs, so ticks, gradients, needles and zones all still work on them and script still sets their values directly. All it solves is the arithmetic of nesting rings evenly.
| Attribute | Default | What it does |
|---|---|---|
thickness | 0.043 | Ring weight as a fraction of the box, so every ring gets the same pixel stroke. |
gap | 0.065 | Space between rings, also a fraction of the box. |
Size comes from --cths-size. Put anything in the middle with slot="center". A ring the box has no room for is dropped rather than drawn inside out, and the layout redoes itself on resize. | ||
<cth-level>
| Attribute | Default | What it does |
|---|---|---|
value / min / max | 0 / 100 | The column and its scale. |
liquid | — | Wavy surface instead of a flat bar. |
bulb | — | Adds a reservoir at the foot — a thermometer. |
ticks / tick-major | — | Graduations down the side, every Nth one labelled. |
zones | — | Bands painted over the column, so they tint the fluid sitting in them. |
readout | value | value, percent or none. |
unit / decimals / label | — | As on <cth-knob>. |
color | — | Shorthand for --cthl-fill. Height is --cthl-height. |
<cth-horizon>
| Attribute | Default | What it does |
|---|---|---|
pitch | 0 | Nose-up in degrees. Positive slides the horizon down the face. |
roll | 0 | Right bank in degrees. Positive lifts the horizon's right-hand end. |
ladder-step | 10 | Degrees between pitch-ladder rungs. |
ladder-max | 30 | Highest rung drawn, either side of the horizon. |
// contacts are a property, not just an attribute radar.blips = [{ bearing: 45, range: 0.6 }]; radar.scatter(6); radar.addEventListener('cth-detect', (e) => hit(e.detail.bearing));
Theming
cth-knob {
--cth-size: 180px; /* everything scales from this */
--cth-thickness: 10; /* unitless, in a 0–100 viewBox */
--cth-value: #019ae6;
--cth-track: #d9dce1;
--cth-duration: 600ms;
}
/* internals are reachable too */
cth-knob::part(value) { filter: drop-shadow(0 0 5px currentColor); }
Events
knob.addEventListener('cth-change', (e) => console.log(e.detail.value));
30 dashboards. Seven elements.
The instrument lab is a stress test disguised as a showcase: aircraft six-packs, a submarine sonar, brass steampunk dials, a reactor core, a 96-dial server wall and a world-clock departure board — all built from the seven elements and a handful of CSS variables.
Open the instrument lab →cth-knob cth-heat cth-radar cth-horizon cth-level cth-ringscth-trace