Your laptop's brightness keys, with usable steps at the dark end.
A single small tray app for Windows. No admin, no background service, no telemetry.
Windows moves screen brightness in jumps of 10. At the bottom of the range that is the difference between "too dark to read" and "too bright for a dark room", with nothing in between:
Windows 0 ─────────── 10 ─────────── 20 ─────────── 30 ...
^ nothing usable lives in here ^
BrightnessSteps
0 1 2 3 4 5 6 8 10 13 16 20 25 30 37 ...
^ 1-point steps where your eyes need them ^
It also goes below hardware zero. Most laptop panels stop at a backlight level that is still too bright in a properly dark room, so the last few steps dim the screen further, down to fully black.
Download the installer from Releases, or take the portable
zip and run BrightnessSteps.exe directly — it is a single self-contained
executable that needs nothing installed.
Windows will say the publisher is unknown. The installer is not code-signed: a certificate costs more per year than this project will ever make. Choose More info → Run anyway, or build it yourself —
build.ps1needs nothing but thecsc.exealready inC:\Windows\Microsoft.NET.
- Installs per user, so there is no UAC prompt
- Needs .NET Framework 4.x, which ships with Windows 8 and later
- Optional "start when I sign in", toggleable later from the tray menu
Building from source needs nothing but Windows: build.ps1 compiles with the
csc.exe already in C:\Windows\Microsoft.NET.
Probably, if it is a Windows laptop with brightness keys. Nothing in it is
specific to a vendor, model or keyboard layout — keys are matched on the
standard HID Consumer Page usages 0x6F/0x70 (Display Brightness
Increment/Decrement), decoded through the device's own HID report descriptor,
not by scancodes, key positions, or the Fn state.
If your keys are not the standard ones, teach it. Right-click the tray icon
and choose Set up brightness keys, or run BrightnessSteps.exe --setup. It
asks you to press your darker key, then your brighter key, while listening to
every HID collection on the machine — and takes the signal that appears for one
and not the other. That covers vendors who use their own usage pages instead of
the standard ones, without anybody having to guess what each vendor does. The
result is saved to %APPDATA%\BrightnessSteps\keys.cfg; delete it to go back
to the defaults.
Two things still have to be true, and the app tells you if they are not:
| requirement | if it is missing |
|---|---|
| A built-in panel that accepts brightness commands | Tray icon says so on startup |
| Brightness keys that reach Windows at all (HID usage or virtual key) | Setup finds nothing; the keys are handled below the OS on that model |
It will not work with external monitors. Those need DDC/CI over the video cable, which is a different mechanism entirely; this drives built-in laptop panels through the display driver.
If it does not work for you, right-click the tray icon and choose Copy compatibility report. That collects your Windows version, machine model, which brightness method was found, and whether any keys were seen — nothing else, nothing personal — puts it on the clipboard and opens it so you can read it before sending. Paste it into an issue. Nothing is ever transmitted on its own.
Developed and measured on a Surface Book 3. Reports from other machines are the most useful thing you can contribute.
26 rungs, darkest to brightest:
| region | rungs |
|---|---|
| below hardware zero (black overlay) | black (fully dark) / dim 91% / 80% / 67% / 47% / 24% |
| hardware brightness | 0 1 2 3 4 5 6 8 10 13 16 20 25 30 37 45 55 67 82 100 |
1-point steps up to 6, then gradually wider, so every press is about the same
relative change rather than the same absolute one. To change it, edit the
Ladder array in BrightnessSteps.cs and run build.ps1.
The darkest rung is a fully opaque black sheet — the screen goes completely dark. Brightness-up still works from there, the level popup draws above the sheet so there is always something visible, and the sheet belongs to this process, so killing it (or Ctrl+Alt+Del, which switches to the secure desktop) always gets the screen back.
The rest of this file is the engineering log: what was measured, what was tried and rejected, and why the code looks the way it does. It is here because almost every interesting decision in this app was forced by a measurement that contradicted the obvious approach.
Three things were measured on this machine before the design was fixed:
- The brightness keys are invisible to a low-level keyboard hook. Firmware / Windows handles them below user mode, so they cannot be swallowed and replaced the usual way.
- They are visible to raw input, as HID consumer-control reports — usage
0x6F(up) and0x70(down) — arriving about 20 ms before Windows applies its own ±10 step. So the direction of every press is known unambiguously, which is what separates a key press from someone dragging the slider. - Gamma-ramp dimming is clamped by default.
SetDeviceGammaRamprefuses anything below about 60% unlessHKLM\...\ICM\GdiIcmGammaRangeis set, which needs admin plus a reboot. So there are two ways to go below hardware zero, and the app picks at startup — see Below hardware zero below.
Given that, each press works as: apply our rung the instant the HID report arrives, then hold it. Windows still stomps on it ~20 ms later, so a guard loop watches the backlight for 220 ms afterwards and puts the value straight back (see the driver section below for how often it is safe to do that).
External brightness changes with no preceding key press — the slider, battery saver, adaptive brightness — are followed rather than fought, and clear the overlay. The guard window is excluded from that, since during it we are deliberately overriding Windows.
The brightness event's value is deliberately ignored. It reports brightness
as it was at the moment of the change, and delivery can lag by hundreds of
milliseconds (224 ms observed) — long enough for the guard to have already put
the value back. Acting on that stale number resynced the ladder to Windows'
stomp and cleared the overlay, which in the dim zone shows up as a jump to the
0 rung and back. The handler now treats the event purely as a nudge that
something changed and re-reads the hardware, which is cheap. It compares against
the current rung's own hardware value rather than a recency window, too: with a
2.5 s echo window, sitting in the dim zone longer than that let any unrelated
brightness event clear the overlay out from under it.
Windows' step cannot be blocked, so it is always briefly visible. The only thing left to control is how long. Measured on this machine:
| operation | cost |
|---|---|
WmiSetBrightness (the obvious API) |
10.1 ms median |
IOCTL_VIDEO_SET_DISPLAY_BRIGHTNESS |
0.16 ms median |
So brightness is read and written straight through the monitor device
(\\?\display#..., opened unelevated), which is the layer underneath WMI. WMI
is kept only as a fallback if the device cannot be opened.
That makes tight polling affordable. Measured, with the stomp coming from an independent device handle so it stands in for Windows rather than queueing behind the guard's own lock:
| guard strategy | median | max |
|---|---|---|
Sleep(1) polling |
2.04 ms | 2.55 ms |
| spin polling | 0.27 ms | 0.44 ms |
| WMI event driven | 3.29 ms | 224 ms |
So the guard spins for the first 70 ms after a press — the window Windows' stomp
actually lands in — then drops to Sleep(1) for the tail. The event-driven
option looks tempting and is the worst of the three: it is slower in the median
and occasionally hundreds of milliseconds late.
Two consequences worth knowing:
- The guard owns a separate device handle from the rest of the app. While spinning it takes that handle's lock every fraction of a millisecond, and sharing one handle would stall the UI thread's own writes behind the spin. (Measuring this wrong — with a shared lock — made spinning look worse than sleeping, which it is not.)
- Spinning costs one core for up to 70 ms per press, and a held key re-arms it, so it gives up after 3 s of continuous spinning. Idle cost is still zero: the guard thread blocks on an event between presses.
No, not from user mode. RegisterRawInputDevices has flags for exactly this —
RIDEV_NOHOTKEYS and RIDEV_NOLEGACY — and they are accepted for the keyboard
collection but rejected for the consumer-control collection the brightness keys
actually arrive on:
| collection | NOHOTKEYS |
NOLEGACY | NOHOTKEYS |
|---|---|---|
consumer control 0C/01 (brightness keys) |
rejected, ERROR_INVALID_FLAGS |
rejected, ERROR_INVALID_FLAGS |
keyboard 01/06 |
accepted | accepted |
Disabling the HID device would stop Windows acting on the keys, but it would stop us seeing them too. Short of a HID filter driver, the step can only be raced, never prevented — so what is left is arming the race as early and as reliably as possible:
- The guard is armed before any other work in a key press. It only needs the target number, so the overlay, DWM and the popup must not sit in front of it. Arming late is what let the occasional stomp slip through.
- The guard thread runs at
ThreadPriority.Highest. It is blocked between presses, but must be scheduled promptly when woken. - Raw input is pumped on its own thread, not the UI thread.
WM_INPUTbehind the overlay's 10 ms animation ticks and the popup's painting is late input, and those are busiest exactly when keys repeat in the dim region. - The spin ceiling is a stuck-key backstop only. At its original 3 s, a normal
hold from full brightness down to black ran past it: the guard loop never
exits while repeats keep extending it, so
startedAtstayed fixed and the guard quietly dropped to ~2 ms latency mid-hold. That is why the flicker came back specifically when holding or pressing fast, and specifically near zero where 2 ms is enough to see.
The single biggest cause of flicker under fast key presses turned out to be self-inflicted. Measured on this panel:
- A brightness change takes 3-14 ms to settle.
- A write issued while an earlier change is still settling is often dropped outright — a single correction sticks only about 5-8 times out of 8, regardless of how soon after the foreign write it is issued. There is no timing window that reliably wins, so the guard has to re-issue until a read confirms the value.
- Re-issuing on every poll, which is what the guard originally did, fires
~140,000 writes per second. That keeps the driver permanently busy, so the
correction never lands at all and Windows' steps pile up on top of each other:
guard saw 92, restoring 82repeating every 7 microseconds, forever.
So the guard now paces its reads (~100 µs) and rate-limits its writes (~3.3 ms,
measured flat against 1.4 ms and better than 5.9 ms). Under --selftest, worst
drift at 35 ms between presses went from 33 points to 10; at 120 ms it is 0.
The remaining 10 points is one uncorrected step still settling at the moment of sampling, and it is a property of the display driver rather than of this code.
Every limit here is an API restriction or driver behaviour, not an access check. Tested elevated and unelevated:
| lever | result |
|---|---|
RegisterRawInputDevices with NOHOTKEYS on 0C/01 |
ERROR_INVALID_FLAGS either way — flag validation, not privilege |
Exclusive CreateFile on the consumer-control HID collections |
ERROR_SHARING_VIOLATION (32) either way, on all four collections |
Stopping DisplayEnhancementService (needs admin) |
Windows still stepped brightness: 151 key presses, 48 changes |
The HID result is the informative one. The failure is a sharing violation, not
ACCESS_DENIED — the kernel input stack already holds those collections open,
and sharing is enforced by the object manager against existing handles. No
privilege overrides that. Elevated output was byte-identical to unelevated.
Stopping the display service did change Windows' behaviour — its stepping degraded to toggling between 0 and 13 — but it did not stop it, and it is not where the hotkey is handled.
So the app deliberately does not request elevation: it would cost a UAC prompt on every boot and buy nothing. The only remaining way to truly block the key is a kernel-mode HID filter driver, which needs test-signing or an EV certificate — far past what this is worth.
Two mechanisms, chosen at startup, in this order:
| how | trade-off | |
|---|---|---|
| gamma ramp (preferred) | scales the display's gamma curve | invisible to screenshots and screen shares, never on top of fullscreen apps — but needs the clamp lifted, and outlives the process |
| black overlay (fallback) | click-through layered window | always available, no admin — but it is a real window, so it captures and it sits above fullscreen apps |
Gamma is only chosen if a deep ramp is actually accepted: the registry value can be set while a driver still refuses, so the app applies a 30% ramp and restores it in the same breath (sub-millisecond, invisible) and only commits to gamma if that took.
Because a gamma ramp survives the process that set it, killing the app while the
screen is dimmed would otherwise leave it dark. So the original ramp is saved to
%APPDATA%\BrightnessSteps\gamma-original.bin and a marker file is written
while dimmed; the next start sees the marker and puts the ramp back. The overlay
has no such problem — it dies with its process.
The setup wizard offers to lift the clamp for you (one administrator prompt, takes effect after a restart). Until then, the overlay is used and everything still works.
By default Windows only permits mild gamma dimming. Measured on this machine, before any change:
| requested | result |
|---|---|
| 60 % | applied |
| 30 %, 10 %, 2 %, 0 % | SetDeviceGammaRamp returns false, ramp ignored |
That is why below-zero dimming uses a layered black window. Setting
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ICM\GdiIcmGammaRange = 256
(admin, then a reboot) lifts the clamp, which would allow gamma dimming instead
— nothing in screenshots or screen shares, nothing sitting on top of fullscreen
apps, and none of the window-management bugs the overlay caused.
It would not reduce flicker, which is about backlight writes, and gamma can be reset by other colour-management software, so the overlay stays as the fallback either way.
To undo it, run elevated:
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ICM' `
-Name GdiIcmGammaRangethen reboot. That restores the Windows default; nothing else depends on it.
Raw HID reports cannot be injected, so BrightnessSteps.exe --selftest drives
the app's real key path and plays Windows' part on a second device handle,
adding ±10 about 20 ms after each press. It sweeps the ladder in both directions
at several press intervals and reports the worst drift between the hardware and
the rung that should be showing. It skips the singleton, so stop the running
instance first. Trace records presses, corrections and driver fallbacks to
selftest-trace.txt; it is off unless the self-test turns it on.
A 10-point step is a ~10 % change at the top of the range and a ~10x change at the bottom, so the same 2 ms is invisible mid-range and obvious near zero. Two separate causes turned up there:
- Stepping up out of the dim region. Hardware sits at 0, Windows' step takes it to 10, and against a near-black screen that is a huge relative jump. Fixed by the spin guard above.
- Stepping down into the dim region. Hardware is already 0 and Windows'
step is a no-op, so this one was entirely the overlay: the window was created
lazily on its first
Show(), and the layered alpha got applied a frame after the window first appeared — one frame of fully opaque black. The overlay window is now built up front, and alpha is always set before it is revealed.
One pleasant side effect of acting before Windows: Windows computes its step from whatever brightness it finds, which by then is already our new rung. So a press downward makes it aim 10 lower still and clamp toward 0 — the residual blip goes darker rather than flashing bright, which is much easier on the eyes at night. Presses upward still blip bright.
Below hardware zero there is no flicker at all going down: hardware is already pinned at 0, Windows' step cannot go lower, and only the overlay changes.
- Start with Windows — adds/removes the
HKCU\...\Runentry - Show level popup — the small readout of the true level (Windows' own popup shows the value it tried to set, which is not the one that ends up applied)
- Darker / Brighter — step the ladder without the keys
- Exit
Free, and staying that way. If it made your evenings easier, there is a coffee fund — entirely optional. Telling me it does not work on your laptop is worth more, and costs nothing.
| file | purpose |
|---|---|
BrightnessSteps.cs |
the whole app, single file |
build.ps1 |
compile and restart |
installer/ |
Inno Setup script for the per-user installer |
tools/makeicon.cs |
draws app.ico at eight sizes |
tools/publish.ps1 |
creates the repo and cuts a release |
site/ |
the landing page, its deploy script, and the analytics endpoint |
tools/stats.mjs |
prints the site's visit and download counts |
Run BrightnessSteps.exe --selftest to drive the key path against a simulated
Windows step and print the worst drift; see Testing without a keyboard above.