Make Your Own Face
Build your own meter faces with HTML, CSS and JavaScript. Every face works with every source, from the battery to numbers you send it yourself.
Quick Start
- Create a face: In Settings, click Create Face… (Option-Command-N). Multimeter makes a folder of starter files that already use the version 2 data.
- Edit the files: Multimeter shows the folder in Finder. Edit the files in any code editor.
- See your changes live: Multimeter reloads your face every time you save. Try it on different sources in Add Meter… or Customize This Meter….
- Share it: Compress the folder in Finder, rename the .zip to
.multimeter, and submit it to the gallery. Anyone can add it with Import… (Command-O) or by double-clicking the file.
How a Meter Works
A meter in the menu bar is a face plus a source plus the person's settings. The face is your folder: a small web page that Multimeter loads into a transparent view 22 points tall, as wide as width in your manifest. The source is what the meter measures, picked by the person when they add the meter.
Whenever anything changes, Multimeter sets window.meterData and sends a meterUpdate event. Your face doesn't need to know where the numbers come from: draw level (0 to 1) and show formatted, and it works for CPU, network, a timer or the battery alike.
File Structure
Each face is a folder containing:
my-meter/
├── manifest.json # name, size, which sources it's made for
├── index.html # the page Multimeter loads
├── styles.css
└── meter.js # listens for meterUpdate and drawsmanifest.json
The manifest describes your face and tells Multimeter how wide it is and what it's made for:
{
"name": "My Meter",
"description": "What it shows, in one line.",
"version": "1.0.0",
"author": "Your Name",
"apiVersion": 2,
"sources": ["any"],
"defaultSource": "cpu",
"width": 60,
"minWidth": 40,
"maxWidth": 100,
"supportsLightMode": true,
"supportsDarkMode": true
}| Key | Type | What it means |
|---|---|---|
name | string | Shown in the face list and the gallery. |
description | string | One line about what the face shows. |
version | string | Your version number, like 1.0.0. |
author | string | Your name, shown in the gallery. |
apiVersion | 2 | Set to 2 for the data described on this page. |
sources | string[] | ["any"] for a face that can draw any source, or a list of source ids like ["battery"] or ["battery", "countdown", "timer"]. Multimeter only offers your face for those sources. |
defaultSource | string | The source picked when someone adds your face first, like "cpu". |
width | number | Width in points. The height is always 22. |
minWidth / maxWidth | number | The narrowest and widest the person can make it in Customize. |
supportsLightMode / supportsDarkMode | boolean | Whether the face is designed for light and dark menu bars. |
A manifest without apiVersion and sources is treated as a battery-first face that still works with any source, so meters made for earlier versions keep working.
Meter Data
Multimeter puts the current data in window.meterData and sends it again on every change. Here is everything in it (API version 2):
window.meterData = {
apiVersion: 2,
source: "cpu", // what this meter measures
sourceName: "CPU", // that source's name, translated
label: "CPU", // the meter's label ("" if the person cleared it)
value: 23.4, // raw value in `unit` (null if unavailable)
min: 0, max: 100, // the range `level` is measured against
level: 0.234, // value placed in the range, 0 to 1
formatted: "23%", // ready to show, unit included, in the person's language
unit: "%", // "%", "W", "B/s", "rpm", "s", or your own
state: "normal", // "normal", "warning" or "critical"
tint: null, // "#30D158" if the person picked a color
history: [0.21, 0.25], // up to 60 recent levels, oldest first
warningLevel: 0.7, // alert levels on the same 0 to 1 scale (or null)
criticalLevel: 0.9,
alertDirection: "above", // "above" or "below" (or null)
available: true, // false when the source can't be read right now
colorScheme: "dark", // the menu bar: "light" or "dark"
reduceMotion: false, // System Settings > Accessibility > Reduce motion
strings: { timeLeft: "left" },
battery: { // always the Mac's battery, whatever the source
level: 0.82, percentage: 82, isCharging: false, isPluggedIn: false,
hasBattery: true, timeRemaining: 312, timeToFullCharge: null,
formattedTime: "5:12", cycleCount: 212, health: 0.94, temperature: 30.5
},
// Version 1 fields, so battery meters keep working with any source:
percentage: 23, isCharging: false, isPluggedIn: false,
timeRemaining: null, timeToFullCharge: null, formattedTime: null,
cycleCount: 212, health: 0.94, temperature: 30.5, hasBattery: true
}| Field | What it means |
|---|---|
source | The source id (see the table below). sourceName is its translated name. |
label | The meter's label, like CPU or DOWN. The person can rename it or clear it, so it may be an empty string. |
value | The raw reading in unit, or null. With min and max it gives you the full picture. |
level | The value placed in the range from 0 to 1, clamped. Most faces only need this. |
formatted | The reading as text, unit included, formatted for the person's language: 23%, 12.4 W, 1.2 MB/s, 4:12. |
unit | %, W, B/s, rpm, s (timers and countdowns), an empty string, or the person's own unit. |
state | normal, warning or critical, from the alert levels the person set. Color yourself by it. |
tint | A color like #30D158 that the person picked, or null to use your face's own colors. |
history | Up to 60 recent levels (0 to 1), oldest first, for graphs. |
warningLevel | warningLevel and criticalLevel place the alert levels on the same 0 to 1 scale as level, and alertDirection says whether alerts fire above or below them. All three are null when the meter has no alerts. |
available | false when the source can't be read right now: no battery, the accessory is off, a web address is failing. Show a dash. |
colorScheme | The menu bar's appearance, light or dark. It follows the wallpaper, not just the system setting. |
reduceMotion | true when Reduce motion is on. Skip animations. |
battery | The Mac's battery, always there whatever the meter measures: charge, charging, plugged in, time left, time to full, cycles, health and temperature. |
Listening for Updates
Draw once with whatever is already in window.meterData, then again on each meterUpdate. Set window.meterReady = true when you're ready for data.
function render(data) {
// draw the meter from data.level, data.formatted, data.state ...
}
window.addEventListener('meterUpdate', (e) => render(e.detail));
render(window.meterData || { level: 0.5, formatted: '50%', state: 'normal' });
window.meterReady = true;Sources
These are the sources a meter can have. Your face sees the id in source and gets formatted already written, so you rarely need to treat them differently.
| Source | source | unit | formatted looks like |
|---|---|---|---|
| Battery | battery | "%" | 82% |
| Bluetooth Accessory | bluetooth | "%" | 64% |
| Power Draw | power | "W" | 12.4 W |
| CPU | cpu | "%" | 23% |
| Memory | memory | "%" | 71% |
| Disk Space | disk | "%" | 58% |
| Thermal Pressure | thermal | "" | Nominal, Fair, Serious, Critical |
| Fan Speed* | fan | "rpm" | 2,400 rpm |
| Network | network | "B/s" | 1.2 MB/s, 340 KB/s |
| Timer | timer | "s" | 24:51 |
| Countdown | countdown | "s" | 3d 4h, 4:12, 12:05 |
| Day Progress | progress | "%" | 63% |
| Sent Value | push | "" | 42 |
| Web Address | url | "" | 1,284 |
| File | file | "" | 12 |
| Script* | script | "" | 412 |
* Fan speed and scripts need the direct download version of Multimeter. The Mac App Store version can't read the fan sensors or run your scripts; use a sent value or a file there instead.
Percentages always run from 0 to 100. Power, network and fan speed scale to their recent peak unless the person sets a range. Thermal pressure has four steps, so level is 0, 0.33, 0.67 or 1. For timers and countdowns, level is the share of time left and value is the seconds left.
Alerts and Colors
Each meter can have a warning and a critical level, set by the person (battery defaults to below 20% and 10%, CPU to above 70% and 90%, disk to above 85% and 95%). Multimeter works out state for you. A face should look calm when it's normal and get louder as it goes warning and critical, and use tint when the person picked a color:
document.body.classList.remove('warning', 'critical');
if (data.state !== 'normal') document.body.classList.add(data.state);
// The person's color wins over the face's own
if (data.tint) document.body.style.setProperty('--accent', data.tint);
else document.body.style.removeProperty('--accent');body.warning { --accent: #ff9f0a; }
body.critical { --accent: #ff453a; }Want to draw the alert zones on a scale, like a dial with a red end? Use warningLevel, criticalLevel and alertDirection.
History
history holds up to 60 recent levels, oldest first, already on the 0 to 1 scale. Add the current level to the end and you have a graph. The History Graph example below draws one.
Battery Details
The battery is still special. data.battery is always the Mac's battery, whatever the meter measures, so any face can show a charging bolt. A face made only for the battery should say "sources": ["battery"] in its manifest. timeRemaining and timeToFullCharge are in minutes and are null while macOS is still estimating.
// A battery-only face: "sources": ["battery"] in manifest.json
const b = data.battery;
bolt.hidden = !b.isCharging;
time.textContent = b.isCharging
? (b.timeToFullCharge ? `${b.timeToFullCharge} min to full` : '')
: (b.formattedTime ?? '');Faces written for version 1 read percentage, isCharging and formattedTime at the top level. Those are still sent: for the battery source they're the battery's own values, and for other sources isCharging is false and formattedTime is the time left on a timer or countdown (h:mm), otherwise null.
Your Own Data
Four sources show numbers you supply: a sent value, a web address, a file or a script. Your face treats them like any other source.
Send a value with a link
Open a multimeter://set link from anywhere: Terminal, a shell script, Shortcuts (Open URLs), Raycast, a cron job, a build script. Add a meter with the Sent Value source and the same key, and it updates the moment the link opens. open -g keeps Multimeter in the background.
# Set a value (the meter shows the "deploys" key)
open -g "multimeter://set?key=deploys&value=42"
# Range, label, unit and your own text ride along
open -g "multimeter://set?key=coffee&value=3&min=0&max=5&label=Coffee&unit=cups"
open -g "multimeter://set?key=build&value=0.8&max=1&formatted=80%25%20done"| Parameter | What it means |
|---|---|
key | Which meter it's for. Keys ignore case. |
value | The number. Required. |
min / max | The range, so level comes out right. Defaults to 0 to 100. |
label | Text for the meter's label. |
unit | A unit added after the number, like cups. Use % for a percentage. |
formatted | Your own text for formatted, used instead of the number. |
Sent values work in the Mac App Store version too. Multimeter remembers the last value for each key.
Web addresses, files and scripts
All three read the same output: a plain number, or JSON with a value and any of the extras. Everything except value is optional.
42
42.5
42%
{"value": 42, "min": 0, "max": 100, "label": "Deploys", "unit": "", "formatted": "42 left"}Pointing at bigger JSON, like an API response? Give the meter a key path and it picks the number out:
{"data": {"count": 17}} → key path data.count
{"items": [{"value": 9.5}]} → key path items.0.value- Web address: fetched on an interval you choose, every 30 seconds at the most (every 5 minutes unless you change it).
- File: read again whenever the file changes. Handy for anything that can write a file.
- Script: a shell command run with
zshon an interval you choose (every 30 seconds unless you change it). Multimeter shows the number it prints. Homebrew's folders are on thePATH, and a command that takes longer than 10 seconds is stopped.
# Open pull requests waiting on you (direct download version)
gh pr list --search "review-requested:@me" --json number --jq length
# Free memory pressure as a percentage
memory_pressure | awk '/percentage/ {print 100 - $5}'The Mac App Store version can use web addresses and files, but not scripts. For scripts there, have your script send a link or write a file.
Light and Dark Menu Bars
The menu bar is light or dark depending on the wallpaper. Multimeter adds light-mode or dark-mode to body, sets colorScheme in the data, and prefers-color-scheme matches the menu bar too. Use whichever you like:
/* Dark menu bar (the default) */
body { color: white; }
/* Light menu bar */
body.light-mode { color: black; }
/* Same thing with the media query, which follows the menu bar too */
@media (prefers-color-scheme: light) {
body { color: black; }
}Reduced Motion
When Reduce motion is on, reduceMotion is true and prefers-reduced-motion matches. Keep the meter readable and drop the bounces, pulses and sweeps:
body.reduce-motion * { transition: none !important; animation: none !important; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}Complete Examples
Two complete faces, running live on this page through the same data Multimeter sends. Copy the files into a folder and import it.
The Create Face starter
Exactly what Create Face… writes: a label, a bar and the formatted value, with the API in its comments. It colors itself by state, honors tint, and follows the menu bar's appearance.
starter/manifest.json
{
"name": "My Face",
"description": "A label, a bar and the value. Works with any source.",
"version": "1.0.0",
"author": "Your Name",
"apiVersion": 2,
"sources": ["any"],
"defaultSource": "cpu",
"width": 70,
"minWidth": 50,
"maxWidth": 120,
"supportsLightMode": true,
"supportsDarkMode": true
}starter/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="meter">
<span id="label"></span>
<span id="track"><span id="fill"></span></span>
<span id="value">--</span>
</div>
<script src="meter.js"></script>
</body>
</html>starter/styles.css
* { margin: 0; padding: 0; box-sizing: border-box; }
/* The meter is 22 points tall and as wide as manifest.json says. */
body {
font: 600 11px/1 -apple-system, BlinkMacSystemFont, sans-serif;
font-variant-numeric: tabular-nums;
color: white; /* dark menu bar */
background: transparent;
height: 22px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
--accent: #30d158; /* replaced by the meter's color, if one is set */
}
/* Light menu bar: the system setting, or the preview's light mode. */
#track { background: rgba(255, 255, 255, 0.25); }
@media (prefers-color-scheme: light) { body { color: black; } #track { background: rgba(0, 0, 0, 0.18); } }
body.light-mode { color: black; }
body.light-mode #track { background: rgba(0, 0, 0, 0.18); }
body.dark-mode { color: white; }
body.dark-mode #track { background: rgba(255, 255, 255, 0.25); }
#meter { display: flex; align-items: center; gap: 4px; }
#label { font-size: 8px; letter-spacing: 0.04em; opacity: 0.7; }
#track { width: 18px; height: 6px; border-radius: 3px; position: relative; overflow: hidden; }
#fill { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--accent); border-radius: 3px; transition: width 0.4s ease; }
/* state is "normal", "warning" or "critical" (from the meter's alert levels). */
body.warning { --accent: #ffd60a; }
body.critical { --accent: #ff453a; }
@media (prefers-reduced-motion: reduce) { #fill { transition: none; } }starter/meter.js
// Multimeter meter API, version 2. The app sets window.meterData and fires
// a 'meterUpdate' event whenever anything changes. The fields you'll use most:
//
// level 0.0 to 1.0, the value within the meter's range
// formatted ready-to-show text with its unit: "23%", "1.2 MB/s", "4:12"
// label short label ("CPU"), may be "" if hidden
// value, unit, min, max the raw number and its range
// state "normal", "warning" or "critical"
// tint the color picked in Customize ("#30D158"), or null
// history up to 60 recent levels (0 to 1), oldest first, for graphs
// source what it measures: "battery", "cpu", "memory", "network", "timer"...
// available false when there's nothing to read (no battery, URL failing)
// battery the Mac's battery, always: level, isCharging, isPluggedIn,
// hasBattery, timeRemaining, timeToFullCharge, health...
// isCharging true only when this meter shows the battery and it's charging
// colorScheme "light" or "dark"; reduceMotion true if animations should stop
//
// Full reference: https://multimeterapp.com/creating-meters
class Meter {
constructor() {
this.label = document.getElementById('label');
this.fill = document.getElementById('fill');
this.value = document.getElementById('value');
window.addEventListener('meterUpdate', (e) => this.render(e.detail));
if (window.meterData) this.render(window.meterData);
window.meterReady = true;
}
render(data) {
const level = Math.max(0, Math.min(1, data.level || 0));
this.label.textContent = data.label || '';
this.label.style.display = data.label ? '' : 'none';
this.value.textContent = data.available === false ? '\u2013' : (data.formatted || Math.round(level * 100) + '%');
this.fill.style.width = (level * 100) + '%';
document.body.classList.toggle('warning', data.state === 'warning');
document.body.classList.toggle('critical', data.state === 'critical');
if (data.tint) document.body.style.setProperty('--accent', data.tint);
else document.body.style.removeProperty('--accent');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new Meter());
} else {
new Meter();
}History Graph
A line graph of the last minute from history, drawn on a canvas at the screen's real resolution.
history-graph/manifest.json
{
"name": "History Graph",
"description": "The last minute as a little line graph.",
"version": "1.0.0",
"author": "Your Name",
"apiVersion": 2,
"sources": ["any"],
"defaultSource": "network",
"width": 44,
"minWidth": 30,
"maxWidth": 80,
"supportsLightMode": true,
"supportsDarkMode": true
}history-graph/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
html, body { margin: 0; height: 22px; background: transparent; overflow: hidden; }
canvas { display: block; width: 100%; height: 22px; }
</style>
</head>
<body>
<canvas id="graph"></canvas>
<script src="meter.js"></script>
</body>
</html>history-graph/meter.js
const canvas = document.getElementById('graph');
const ctx = canvas.getContext('2d');
function render(data) {
// Draw at the screen's real resolution so the line stays sharp
const scale = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = 22;
canvas.width = w * scale;
canvas.height = h * scale;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, w, h);
const points = (data.history || []).concat(data.level ?? 0);
if (points.length < 2) return;
const dark = data.colorScheme !== 'light';
const color = data.tint
|| (data.state === 'critical' ? '#ff453a' : data.state === 'warning' ? '#ff9f0a' : dark ? '#fff' : '#1d1d1f');
const x = (i) => (i / (points.length - 1)) * (w - 2) + 1;
const y = (v) => h - 3 - v * (h - 6);
// Soft fill under the line, then the line
ctx.beginPath();
points.forEach((v, i) => (i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v))));
ctx.lineTo(x(points.length - 1), h);
ctx.lineTo(x(0), h);
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fillStyle = color;
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
points.forEach((v, i) => (i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v))));
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
}
window.addEventListener('meterUpdate', (e) => render(e.detail));
render(window.meterData || { history: [0.2, 0.4, 0.3, 0.6, 0.5], level: 0.7 });
window.meterReady = true;Testing Tips
- Keep everything inside 22 points of height, and check your face at its
minWidthandmaxWidth. - Use the system font (
-apple-system) andfont-variant-numeric: tabular-numsso numbers don't jiggle. - Try your face with several sources in Add Meter… and Customize This Meter…: a percentage, network speed, a timer, an unavailable source.
- Check light and dark menu bars, a tint, and the warning and critical states.
- Errors from your JavaScript show up in Console.app. You can also open your
index.htmlin Safari and send it data from the console: - Keep files small and avoid network requests, so the face draws instantly.
// Paste in Safari's console with your index.html open to try a value
window.meterData = { level: 0.92, formatted: '92%', label: 'CPU', state: 'critical',
available: true, colorScheme: 'dark', history: [] };
window.dispatchEvent(new CustomEvent('meterUpdate', { detail: window.meterData }));Ready to create?
Coming soon to the Mac App Store.
Email us about the launch