cth-instrument v1.0
◆ since 2013 · rewritten for 2026

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
voice
oxygen
liquid
☁️
gas
vitals

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.

1

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>
2

Write the tag

Attributes are the whole API. Change one and the dial re-renders itself.

<cth-knob value="78"></cth-knob>
3

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>
that markup, rendered

The same tag in every framework

HTML
<cth-knob value="78">
React
<cth-knob value={v} />
Vue
<cth-knob :value="v" />
Svelte
<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.

📦
Zero dependencies

One file of standard JavaScript. Nothing to audit, nothing to keep updated.

🎯
Any arc

sweep takes any angle — a 360° ring, a 270° gauge, a 180° half, or your own.

🎨
Themed with CSS

Custom properties and ::part(). Style it like any other element.

Accessible by default

role="meter", live ARIA values, arrow keys, and reduced-motion aware.

🖱️
It is an input too

Add interactive and it becomes a draggable, keyboard-operable slider.

📊
Zones, ticks, segments

Red-line bands, graduations and stacked slices without a chart library.

🌗
Light and dark

Both palettes are built in, and anything you set from outside wins.

🔍
Sharp at any size

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

56px
80px
120px
180px

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
OFF ON 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

2025
365 days round one ring
Oct
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

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.

Countdown · stopped
Stopwatch · stopped
Counter · clamped to 0–20
Thermostat · half-degree steps
Compass · heading 000°
Attitude · level, wings level
Gyro · the card turns under a fixed index
VU · needle snaps up, sags back
peak —
Radar · click the scope to add a contact
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.

AttributeDefaultWhat it does
valueminCurrent value. Above max, an inner overflow ring appears.
min / max0 / 100The scale.
sweep360Degrees of arc. 360 is a ring, 270 a gauge, 180 a half.
startautoStart angle. Defaults to the top, or a bottom-centred gap for a partial arc.
benchmarkA target tick drawn on top of the value ring.
zonesColoured bands: "0-60:#22c55e, 85-100:#ef4444"
segmentsStacked slices: "42:#3b82f6, 23:#8b5cf6"
ticks / tick-majorGraduations, and every Nth one drawn heavier.
readoutpercentpercent, value or none.
unit / decimals% / 0Suffix and precision for the number.
labelCaption under the number, and the accessible name.
colorShorthand for --cth-value.
interactive / step— / 1Turns it into a draggable, keyboard-operable slider.
disabledDims it and ignores input.
animate-inGrow from empty on first paint.
needleA 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.
labelsUpright captions spaced round the arc: "N,E,S,W". Every fourth reads heavier.
label-radius29.5How far out the captions sit, in the 0–100 viewBox.
value-2A second value, drawn as a second pointer alongside the first.
rotatingThe card turns under a fixed index, the way a heading indicator works.
buttonThe 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.
trendA rise or a fall shown beside the number, with a drawn arrow and a sign colour. trend-unit suffixes it.
statesA 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.
turn180The middle swings round to this angle and stays. On a toggle button it swings back when released.
spin33The middle turns, in revolutions per minute. On a toggle button it only turns while pressed. Winds up like a motor, coasts down like friction.
gasFills 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.
insetlowWhere the inset sits: low for a chart under the number, fill for a tube up the middle.
pulse60A ring that swells and fades at this many beats per minute, so the rhythm is legible from across the room.
rangeTwo handles with a band between them: range="20 70". Reads back as {low, high}.
endlessNo ends: dragging reports movement, so the value counts past max while the ring wraps round.
ballisticsMeter ballistics: "attack release" in seconds, one number for both. The reading snaps up and sags back instead of tracking the value.
peak-hold1.2Seconds to hold the highest reading before it starts to fall. Draws a marker in --cth-peak.
peak-fallHow fast the held peak decays, in value units per second.
liquidFills the dial with fluid whose surface sits at the value, with drifting waves. Theme with --cth-liquid.
gradientA colour ramp that follows the arc: "#22c55e,#f59e0b,#ef4444". Colour maps to the scale, so the value reveals part of the ramp.

<cth-trace>

AttributeDefaultWhat it does
shapelineline for a strip, ring to wrap it round a circle.
modesweepsweep travels a pen over the old trace; scroll slides the window so the newest sample is always at the end.
samples240How many samples the window holds.
pointsA written-out waveform: "12,40,38,90". No pen, nothing faded.
beatBeats per minute of a built-in ECG. Omit to feed it yourself.
voiceA built-in talker instead: bursts of speech and real silences. Optionally 01 for how loud.
mirrorThe waveform straddles the centre line instead of rising from the floor.
rate125Samples per second while self-driving — the paper speed.
min / max0 / 100The vertical scale.
gridGraph paper behind the trace, ruled square or polar to match the shape.
penpen="none" hides the writing head.
readout-attop leftWhich 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 / amplitude360 / -90 / .18Ring shape only: the arc it covers and how far it deflects.
readoutautoThe rate when beat is set, else the last sample. none to hide.
unit / decimals / label / colorAs 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>

AttributeDefaultWhat it does
valuesThe list: "4,6,9,14,19". Also .values = [...] from script.
shapecellscells for blocks of colour, bars for towers standing off a baseline.
labelsCaptions spaced round the ring: "Jan,Feb,…".
label-radius49 / 47How far out the captions sit, in the 0–100 viewBox.
scale5-stop blue→redColour stops the values are mapped across.
min / maxthe dataThe colour domain. Omit to fit the data.
rows1Split the list across this many concentric rings.
sweep / start360 / -90The arc the cells cover.
interactiveHovering lifts a cell out of the ring and reads it in the middle.
readoutautoThe hovered cell, else the ring’s average; sum for a total, max for the biggest, none to hide.
unit / decimals / labelAs 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>

AttributeDefaultWhat it does
rings4Concentric range rings.
spokes8Bearing spokes. 0 for none.
periodSeconds per sweep revolution. Omit or 0 for a static scope.
labelsBearing captions, spaced evenly from north.
blipsContacts as bearing:range pairs: "45:0.6, 210:0.35".
interactiveClicking 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.

AttributeDefaultWhat it does
thickness0.043Ring weight as a fraction of the box, so every ring gets the same pixel stroke.
gap0.065Space 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>

AttributeDefaultWhat it does
value / min / max0 / 100The column and its scale.
liquidWavy surface instead of a flat bar.
bulbAdds a reservoir at the foot — a thermometer.
ticks / tick-majorGraduations down the side, every Nth one labelled.
zonesBands painted over the column, so they tint the fluid sitting in them.
readoutvaluevalue, percent or none.
unit / decimals / labelAs on <cth-knob>.
colorShorthand for --cthl-fill. Height is --cthl-height.

<cth-horizon>

AttributeDefaultWhat it does
pitch0Nose-up in degrees. Positive slides the horizon down the face.
roll0Right bank in degrees. Positive lifts the horizon's right-hand end.
ladder-step10Degrees between pitch-ladder rungs.
ladder-max30Highest 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.

AviationSubmarineSpacecraftRace SteampunkReactorServerWorld Clock Kitchen+21 more
Open the instrument lab →
  • cth-knob
  • cth-heat
  • cth-radar
  • cth-horizon
  • cth-level
  • cth-rings
  • cth-trace