brightctl
v1.0.0
Published
Control external monitor brightness over DDC/CI on Apple Silicon Macs, with an npx-friendly CLI
Maintainers
Readme
brightctl
██████╗ ██████╗ ██╗ ██████╗ ██╗ ██╗████████╗ ██████╗████████╗██╗
██╔══██╗██╔══██╗██║██╔════╝ ██║ ██║╚══██╔══╝██╔════╝╚══██╔══╝██║
██████╔╝██████╔╝██║██║ ███╗███████║ ██║ ██║ ██║ ██║
██╔══██╗██╔══██╗██║██║ ██║██╔══██║ ██║ ██║ ██║ ██║
██████╔╝██║ ██║██║╚██████╔╝██║ ██║ ██║ ╚██████╗ ██║ ███████╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝Your MacBook's brightness keys work. Your external monitor's don't.
Brightness control for external displays on Apple Silicon Macs, straight from the terminal.
$ npx brightctl get
External display 1
brightness █████████░░░░░░░░░░░ 43%
contrast ███████████████████░ 94%
$ npx brightctl set -5
✓ brightness █████████░░░░░░░░░░░ 43% → ████████░░░░░░░░░░░░ 38%The problem
macOS gives you a brightness slider for the built-in screen and for Apple displays, and nothing for the monitor on your desk. The brightness keys ignore it. Third-party apps solve this with a menu bar icon, a background agent, and a hundred megabytes of Electron you did not ask for.
brightctl is one command. No daemon, no polling, no state to get stuck, no
window to keep open.
$ npx brightctl get
External display 1: brightness 43/100
External display 1: contrast 94/100
$ npx brightctl set -5 # dim by 5%
External display 1: brightness 43 -> 38 (38%)
$ npx brightctl set +5 # and back
External display 1: brightness 38 -> 43 (43%)Install
npx brightctl get # run it without installing anything
npm i -g brightctl # then just: brightctl get
npm i brightctl # or use it as a libraryA prebuilt native addon ships with the package, so there is nothing to compile.
Put it on a key
The exit code is 0 or 1 and the output is plain text or --json, so it
drops into whatever you already use:
brightctl set -5 # Shortcuts, Karabiner, skhd, Raycast
brightctl set 20 --display 1 # a specific monitor
brightctl get --json | jq .brightness.percentCLI
brightctl [get] [display] [--json]
brightctl set <0-100 | +n | -n> [display] [--json]
brightctl list [--json]
brightctl refresh [--json]| Command | What it does |
| --- | --- |
| get | Brightness and contrast (the default) |
| set | Set brightness. +5 / -5 move relative to where you are now |
| list | Every external display, with its DDC chip address |
| refresh | Re-enumerate after you plug a cable back in |
-d, --display <n> picks a monitor by index and --json is for scripts. Colour
and the bars appear only on a terminal — pipes, NO_COLOR and --no-color all
get clean plain text. Bare brightctl prints the banner and the usage summary.
Library
import { getBrightness, setBrightness, listDisplays, VCP, readVcp } from 'brightctl'
const [display] = listDisplays()
const { current, max, percent } = getBrightness(0)
setBrightness(0, 30) // absolute percentage
setBrightness(0, '-10') // relative
const volume = readVcp(0, VCP.VOLUME)
// { ok: false, reason: 'display returned result code 0x1' }Calls are synchronous — DDC is a serialised bus and each transaction takes tens
of milliseconds. readVcp and writeVcp never throw: check .ok.
getBrightness and setBrightness throw DdcError with a .code.
Why it works when other tools don't
Under the hood this is DDC/CI, a VESA protocol from the late 1990s that travels
over the same cable as the video signal. On Apple Silicon it is reachable
through IOAVService, a private API Apple doesn't document.
Two details in that protocol are quietly broken across the ecosystem, and both of them produce the same maddening symptom — the write reports success and the screen never changes:
- The read checksum that most implementations use is one XOR term short. This monitor answers those requests with a polite rejection and no data.
- A single write is accepted at the transport layer and then dropped. The value only sticks if you send it again.
There is a third trap waiting for anyone who parses the reply: the rejection message is checksum-valid, so a checksum-only parser reads it as "brightness 0" instead of an error.
All three are handled here. The practical upshot: brightctl set either changes
your monitor or tells you why it couldn't.
Good to know
- macOS on Apple Silicon.
IOAVServiceis the Apple Silicon path; Intel Macs would need a different transport, so they are not supported. - Node 18+, if you use the library or a global install.
- DDC/CI must be on in the monitor's own OSD menu. It is on by default on just about everything.
- Brightness can reset when a display sleeps or the link re-handshakes. Re-apply it on wake if that bothers you.
- A direct cable beats a dock. Some USB-C hubs drop DDC traffic.
- Writes are verified by reading the value back, so when
setreports success the display really did change.
MIT licensed.
