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

  1. 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.
  2. Edit the files: Multimeter shows the folder in Finder. Edit the files in any code editor.
  3. See your changes live: Multimeter reloads your face every time you save. Try it on different sources in Add Meter… or Customize This Meter….
  4. 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 draws

manifest.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
}
KeyTypeWhat it means
namestringShown in the face list and the gallery.
descriptionstringOne line about what the face shows.
versionstringYour version number, like 1.0.0.
authorstringYour name, shown in the gallery.
apiVersion2Set to 2 for the data described on this page.
sourcesstring[]["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.
defaultSourcestringThe source picked when someone adds your face first, like "cpu".
widthnumberWidth in points. The height is always 22.
minWidth / maxWidthnumberThe narrowest and widest the person can make it in Customize.
supportsLightMode / supportsDarkModebooleanWhether 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
}
FieldWhat it means
sourceThe source id (see the table below). sourceName is its translated name.
labelThe meter's label, like CPU or DOWN. The person can rename it or clear it, so it may be an empty string.
valueThe raw reading in unit, or null. With min and max it gives you the full picture.
levelThe value placed in the range from 0 to 1, clamped. Most faces only need this.
formattedThe 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.
statenormal, warning or critical, from the alert levels the person set. Color yourself by it.
tintA color like #30D158 that the person picked, or null to use your face's own colors.
historyUp to 60 recent levels (0 to 1), oldest first, for graphs.
warningLevelwarningLevel 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.
availablefalse when the source can't be read right now: no battery, the accessory is off, a web address is failing. Show a dash.
colorSchemeThe menu bar's appearance, light or dark. It follows the wallpaper, not just the system setting.
reduceMotiontrue when Reduce motion is on. Skip animations.
batteryThe 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.

Sourcesourceunitformatted looks like
Batterybattery"%"82%
Bluetooth Accessorybluetooth"%"64%
Power Drawpower"W"12.4 W
CPUcpu"%"23%
Memorymemory"%"71%
Disk Spacedisk"%"58%
Thermal Pressurethermal""Nominal, Fair, Serious, Critical
Fan Speed*fan"rpm"2,400 rpm
Networknetwork"B/s"1.2 MB/s, 340 KB/s
Timertimer"s"24:51
Countdowncountdown"s"3d 4h, 4:12, 12:05
Day Progressprogress"%"63%
Sent Valuepush""42
Web Addressurl""1,284
Filefile""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"
ParameterWhat it means
keyWhich meter it's for. Keys ignore case.
valueThe number. Required.
min / maxThe range, so level comes out right. Defaults to 0 to 100.
labelText for the meter's label.
unitA unit added after the number, like cups. Use % for a percentage.
formattedYour 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 zsh on an interval you choose (every 30 seconds unless you change it). Multimeter shows the number it prints. Homebrew's folders are on the PATH, 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 minWidth and maxWidth.
  • Use the system font (-apple-system) and font-variant-numeric: tabular-nums so 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.html in 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