← All writing

Reading the MacBook lid angle

Two bytes from a sensor, and everything it takes to trust them.

macOSIOKitSwift
A clay-style dark grey laptop with coral curtains drawn back from a mint screen, and a blue cursor arcing above it.
The lid moves the curtains. The code decides how far.

UnfoldMyMac makes the desktop respond when you move a MacBook’s lid. I wrote about the interaction design in Designing interactions around a laptop hinge. This piece covers the plumbing underneath it: where the angle comes from, how the app decides whether to believe it, and how a number in degrees becomes a curtain, a blur or a snail coming out of its shell.

All of the code below is from the open-source repository, trimmed in places for reading.

The angle lives in an undocumented HID report

There is no public macOS API for the lid angle. On the MacBooks that have the sensor, it shows up as an Apple HID device, and the app reads it through IOKit. The matching dictionary asks for Apple’s vendor ID, a specific product ID, and the usage page and usage that carry the angle. The value sits in a feature report as a little-endian 16-bit number in bytes one and two.

// Simplified from LidSensor.swift
IOHIDManagerSetDeviceMatching(manager, [
    kIOHIDVendorIDKey as String: 0x05AC,
    kIOHIDProductIDKey as String: 0x8104,
    kIOHIDPrimaryUsagePageKey as String: 0x0020,
    kIOHIDPrimaryUsageKey as String: 0x008A,
] as CFDictionary)

func read() -> Double? {
    var length = report.count
    let result = IOHIDDeviceGetReport(device, kIOHIDReportTypeFeature, 1, &report, &length)
    guard result == kIOReturnSuccess, length >= 3 else { return nil }
    let angle = Double(UInt16(report[1]) | UInt16(report[2]) << 8)
    return (0...180).contains(angle) ? angle : nil
}

Two details matter more than they look. The manager and the device are opened with no options, so the app never seizes the device and other clients keep working. And anything outside 0 to 180 degrees is treated as no reading at all, rather than clamped into a plausible number.

Because the report is undocumented, support varies by model. The source notes that it may be absent on M1 and M2 machines, and some Macs expose the product ID without the usage page that carries the angle. The app treats both cases as “no sensor”. The repository includes a --probe option on its build script so you can check your own Mac before expecting lid effects to work.

A sensor that can disappear

A HID device is not guaranteed to stay where you found it. Sleep and wake can re-enumerate it, and a Mac without the report will fail every read. LidMonitor wraps the raw sensor to handle both.

A reading older than one second marks the sensor unavailable, and the angle becomes nil instead of holding its last value. When reads fail, the monitor rebuilds the sensor after two seconds, then doubles the delay on each failure up to a minute. Without that backoff, a Mac that simply lacks the sensor would rebuild an HID manager every two seconds for as long as the app runs. After wake, the monitor reopens the device straight away and forgets the stale reading.

Polling only when someone is looking

Reading a sensor costs little, but a timer that never stops is still a timer. LidPollingPolicy picks one of three rates:

  • 30 times a second when effects are enabled or a preview is running, so the display gate notices a closing lid within a frame or two.
  • 4 times a second when nothing renders but the angle is on screen in the menu bar or the window.
  • No timer at all when effects are off and nobody is looking at the angle.

While an effect is actually drawing, the renderer reads the sensor before each frame, so the poll only has to keep the gate and the readout current. Frame deltas are also clamped between 1/240 and 1/20 of a second, so a stalled display link cannot make the animation clock jump when it recovers.

From degrees to a closure value

Effects never see degrees. They see closure, a value from 0 (clear) to 1 (complete). Two user settings shape the conversion: the activation angle where the effect starts, between 60 and 180 degrees, and the completion point, a fraction of the remaining travel at which the effect should finish.

// Simplified from EffectMath.swift; closedLid is 5 degrees
static func closure(lid: Double, activation: Double) -> Double {
    min(1, max(0, (activation - lid) / max(1, activation - closedLid)))
}
static func calibratedClosure(_ raw: Double, completionFraction: Double) -> Double {
    min(1, max(0, raw / completionFraction))
}

With the defaults, activation is 125 degrees and completion is 0.8. Lowering the lid to 77 degrees gives a raw closure of 0.4 and a calibrated closure of 0.5, so the effect is halfway. It completes at 29 degrees and holds its final state for the rest of the travel. Finishing early matters because the last few degrees of a closing lid happen quickly and the screen becomes hard to see.

The starting point is deliberately well short of 180. A laptop on a desk usually sits somewhere past upright, nowhere near flat. Early in the project I noted that mapping the full physical range made an ordinary working position look half-folded. The activation setting lets each person anchor the effect to how they actually sit.

There is one more small rule. At the exact trigger angle, live progress starts at 0.004 instead of zero, so the first frame past the threshold is subtly visible. There is no dead travel where the lid moves and nothing happens.

Shaping the motion

EffectContext turns closure into the values shaders use. motion covers the first half of the travel and blends a linear ramp with a smoothstep curve, so it starts responsively and settles gently. finalFade stays at zero until halfway, then rises to one. In practice the first half of the closing gesture moves things and the second half fades them out.

Frost, the original blur effect, uses those values directly. Its blur radius reaches 72 at full coverage and deepens toward the top edge of the desktop. The coverage is raised to the power of 1.35 along that edge, so the blur builds from the top down. Because Frost blurs the real desktop, it captures frames with ScreenCaptureKit. Those frames stay in memory, the capture excludes the app’s own windows so the overlay never blurs itself, and other effects do not need Screen Recording permission at all.

Safety before spectacle

A full-screen overlay tied to a hinge can go wrong in uncomfortable ways. DisplaySafetyGate has five states: lid closed, built-in display unavailable, sensor unavailable, recovering and ready. Anything at or below 5 degrees counts as closed. After a problem clears, the gate waits half a second of stable conditions before it reports ready again.

Lid effects only target the built-in display and pause when it is closed, asleep, unavailable or mirrored. The app does not hold power assertions to keep the screen awake; if macOS wants to sleep, it sleeps. Overlays let clicks pass through, effects start disabled, and Reduce Motion stills continuous animation.

Other inputs, same rules

Hinge Garden, the first creative scene, uses the lid alongside other Mac inputs. A glass snail carrying a tiny greenhouse emerges as you open the MacBook and retreats as you close it. Connecting the charger sends a ripple through the scene.

Sound reactions are optional and only request microphone access when you enable them. The audio callback keeps two numbers: a level and a timestamp. It computes the RMS of each buffer with Accelerate, keeps a brief peak between the scene’s 30 Hz reads, and lets it expire after a quarter of a second. Buffers never leave the callback or reach disk.

A separate detector decides when sustained sound should advance the scene’s one-liners. The level has to stay high for 0.45 seconds, the detector re-arms only after 0.4 seconds of quiet, and there is a five-second cooldown, so a single click on the desk cannot turn the page. Tilt comes from gravity relative to a baseline, and a brief stirring force comes from angular velocity with yaw deliberately ignored, because integrating it would drift.

What I took from it

The read itself is a dozen lines. Most of the code around it decides when to trust the value, how often to ask for it, and what the screen should do when there is no answer. If you want to try this on your own Mac, start with the probe, keep the device open read-only, and treat “no reading” as a normal state your interface has to handle.

Sources & further reading

Another question worth following?

Explore the workGet in touch

A good project often starts here.

Leave me
a what if.

All the places to find me ↗
To Satyajit

There’s an app I wish existed.

Tell me who would use it and what they are trying to do. A rough sketch is plenty to start with.

Talk to me on LinkedInLet’s follow the question.