Brightness keys for external monitors that macOS refuses to control.
A single-file Swift menu bar app, zero dependencies, for panels whose DDC is dead.
You plug a cheap monitor into your Mac. You press the brightness keys. Nothing happens.
macOS talks to external displays over DDC/CI, a tiny protocol that rides along the HDMI or DisplayPort cable. Most monitors support it. Some do not, or claim to and then ignore every command. Mine, a Lenovo L24i-30, is one of those:
$ m1ddc display 1 set luminance 100
100
$ m1ddc display 1 get luminance
0 <- it never applied, and never will
Every brightness tool on macOS (MonitorControl, BetterDisplay, Lunar) has a fallback for this case: software dimming. They paint a translucent black overlay across the screen. That works, but only in one direction. An overlay can make a screen darker. It can never make it brighter.
So the monitor sat at whatever brightness the factory left it, and I was squinting.
The display's gamma table is a lookup table the GPU applies to every pixel before it leaves the port. macOS exposes it. Bend the curve upward and midtones get brighter without touching the backlight at all.
The catch is that a plain gamma lift drags the black point up too, so the picture goes bright but flat. ScreenBright builds a full 256-entry table instead of using the three-parameter formula API, which lets it lift the midtones and then pull the shadows back down with a separate contrast pivot. Blacks stay black.
| Midtone output (input 0.5) | Blacks | Whites | |
|---|---|---|---|
| macOS default | 0.50 | 0.00 | 1.00 |
| MonitorControl 50% overlay | 0.25 | 0.00 | 0.50 |
| Plain gamma lift | 0.66 | 0.00 | 1.00 |
| ScreenBright (lift + contrast 1.18) | 0.69 | 0.00 | 1.00 |
A 37% brighter midtone with the shadows intact. Not a new backlight, but the difference between squinting and not.
- Brightness keys just work. Press them with the cursor on the external display and they step it one native level, 16 steps end to end. Cursor on the built-in display, and macOS handles it as usual.
- Native-style HUD. The same sun-and-blocks overlay macOS shows, drawn on the display you are actually adjusting.
- Menu bar sliders for brightness, contrast, and maximum boost.
- Starts at login, restarts if it crashes. The app writes and manages its own LaunchAgent. The menu toggle removes it.
- Survives sleep, wake, unplug, replug. The table is reapplied every two seconds because macOS resets it behind your back.
- Zero dependencies. One
main.swift, AppKit and CoreGraphics only. Builds in seconds.
Download: grab ScreenBright-1.0.0.zip from the latest release, unzip, drag to /Applications, open. It is ad-hoc signed, so the first launch needs a right-click, Open.
Or build it yourself. Requires Xcode Command Line Tools (xcode-select --install).
git clone https://github.com/aryswisnu/ScreenBright.git
cd ScreenBright
./build.sh --installThat compiles, renders the icon, signs the bundle, copies it to /Applications, and launches it. Grant Accessibility when macOS asks; that is what lets the app intercept the brightness keys. The sliders work without it.
Not on a Mac with an external display? It does nothing, harmlessly. Only non-built-in displays are touched.
Three pieces, all in main.swift.
1. The LUT. For a brightness level L in [0, 1]:
L >= 0.5 -> gamma = 1 - (L - 0.5) * boost, gain = 1 (bend the curve up)
L < 0.5 -> gamma = 1, gain = floor + L * (1 - floor) * 2 (pull the white point down)
then for each of 256 entries: out = 0.5 + (in^gamma * gain - 0.5) * contrast, clamped, and handed to CGSetDisplayTransferByTable. Level 0.5 is exactly the untouched panel.
2. The key tap. A CGEventTap at the session level watches for brightness keys. On an Apple Silicon laptop keyboard they arrive as ordinary keyDown events with key codes 144 and 145, which took a debug log to discover; every reference says they should be NX_SYSDEFINED aux-control events. The app listens for both. If the cursor is on an external display the event is consumed and the level nudged; otherwise it passes through untouched so the built-in display behaves normally.
3. The HUD. A borderless NSWindow at screen-saver level with an NSVisualEffectView, positioned on the external display, faded out after a second.
These cost real hours. If you are building anything similar, read this section first.
Gamma changes revert when your process exits. CGSetDisplayTransferByTable is scoped to the calling process. Run it from a script and the screen flashes bright for a frame, then snaps back. The app has to stay resident.
Accessibility grants are bound to the code signature. Rebuild, re-sign, and your entry in System Settings silently stops working, or vanishes from the list entirely. With ad-hoc signing every build has a new hash. build.sh runs tccutil reset so at least the prompt comes back cleanly.
SMAppService.mainApp.register() rejects ad-hoc signed apps. It returns without error and does nothing. That is why the app writes a LaunchAgent plist itself instead. The upside is KeepAlive, which the modern API does not offer.
Adding an app with the "+" button in Accessibility can create an entry macOS cannot match to the running process. The fix was letting the app raise its own AXIsProcessTrustedWithOptions prompt, which creates a correctly bound entry.
CGSetDisplayTransferByFormula cannot do this. It takes min, max, gamma per channel. Any gamma below 1 lifts the entire curve including the black point. You need the table API to get contrast back.
Software dimming and gamma stack. If MonitorControl is also running, its overlay sits on top of whatever the LUT does. Uninstall it first, or you will be adjusting two things at once and confused about which one is winning.
Everything in the menu is live. A few knobs are defaults-only because they rarely need touching:
defaults write local.screenbright mode -string always # keys always target external, ignore cursor
defaults write local.screenbright upKey -int 145 # if your keyboard reverses them
defaults write local.screenbright downKey -int 144
defaults write local.screenbright darkFloor -float 0.15 # dimmest output at level 0
defaults write local.screenbright blackPoint -float 0.02 # lift crushed shadows if contrast is highQuit and reopen the app after changing these.
- This is a perceptual brightness increase. Backlight output does not change. If your panel is genuinely too dim in a sunlit room, no software will fix that.
- High boost values compress highlights. Around
boost 1.4you will see banding in bright gradients. The default0.78is a reasonable ceiling for photos and video. - The gamma table is per display, but the level is shared. Two external monitors get the same setting.
- Ad-hoc signed, not notarized. On a fresh Mac, Gatekeeper will complain the first time: right-click, Open.
- Tested on macOS 26 on an M2 Pro MacBook Pro with one external display. It should work on 13+, but that is a guess.
| Brighter than factory | Darker | Keys | Needs DDC | |
|---|---|---|---|---|
| MonitorControl | no | overlay | yes | for brightness |
| BetterDisplay | no | overlay | yes | for brightness |
| Lunar | no | overlay | yes | for brightness |
| ScreenBright | yes | yes | yes | no |
If your monitor's DDC works, use MonitorControl. It is more mature and controls the real backlight. ScreenBright exists for the monitors that do not answer.
Turn off Launch at login in the menu, quit, delete the app. To wipe the last traces:
launchctl bootout gui/$(id -u)/local.screenbright
rm -f ~/Library/LaunchAgents/local.screenbright.plist
defaults delete local.screenbrightQuitting calls CGDisplayRestoreColorSyncSettings(), so the display goes back to normal the moment the app exits.
Issues and PRs welcome. Things that would be genuinely useful:
- Confirming key codes on Intel Macs and external keyboards
- Per-display levels for multi-monitor setups
- A proper signed and notarized release
If ScreenBright fixed your monitor, a star helps the next person with a dead DDC find it.
MIT. See LICENSE.