I added a dim screen button to an ambient sound app that people mostly open at bedtime. I tested it on the Android phone sitting on my desk, watched the screen darken, pressed home, watched it come back, and went to sleep pleased with myself.
A few days later someone on iPhone wrote in. The app was closed, they said, and the screen was still dark. They had opened Settings and pushed the slider back up by hand.
I had written exactly one line. That one line was touching two different things on the two platforms.
The same setBrightnessAsync reaches different places
The expo-brightness documentation states this plainly. I was the one who read past it.
| Question | iOS | Android |
|---|---|---|
What setBrightnessAsync changes | The screen brightness itself | An override for the current activity |
| When you leave the app | It stays | It reverts to the system value |
| What brings it back | Locking the device | Leaving the foreground |
| Permission needed | None | None, for app-level brightness |
On Android, the value you set overrides the system brightness only while your activity is in the foreground. Press home and it comes back on its own. My reassuring desk test had simply been riding on that automatic cleanup.
iOS has no per-app brightness layer. The value you write becomes the device brightness, and it stays until the device locks. Quitting the app does not undo it.
So the cleanup is only needed on one side — and that side was the one I had not written.
If the app dimmed the screen, the app gives the brightness back. That sentence is the one I now recall before adding anything that touches a system default.
Read the original value once, before you dim
To restore something you need somewhere to restore it to. getBrightnessAsync() gives you that, but there is a trap sitting right next to it.
If you read again after dimming, you get back the value you set. If your restore variable is overwritten on every call, the second call saves the dim value as your backup, and restoring quietly does nothing.
Capture it once and only once.
import { useRef } from 'react';
import * as Brightness from 'expo-brightness';
const originalRef = useRef<number | null>(null);
async function rememberOnce() {
// Already captured: do not re-read, or we grab the value we just set
if (originalRef.current !== null) return;
originalRef.current = await Brightness.getBrightnessAsync();
}It is worth calling isAvailableAsync() alongside it so you can hide the control entirely where the API is unavailable. A button that visibly does nothing is the least kind outcome.
Restore on app state, not on unmount
At first I put the restore in a useEffect cleanup and left it there. Closing the screen restores the brightness, I thought, and that is enough. It was not.
People leave an app in more ways than closing a screen. They press home, they swipe it away from the app switcher, they tap a notification and land somewhere else. In those paths your cleanup may never run.
So I tied dimming and restoring to AppState instead.
import { useCallback, useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import * as Brightness from 'expo-brightness';
export function useDimScreen(target: number) {
const originalRef = useRef<number | null>(null);
const dimmingRef = useRef(false);
const applyDim = useCallback(async () => {
if (originalRef.current === null) {
originalRef.current = await Brightness.getBrightnessAsync();
}
await Brightness.setBrightnessAsync(target);
}, [target]);
const restore = useCallback(async () => {
const original = originalRef.current;
if (original === null) return;
await Brightness.setBrightnessAsync(original);
}, []);
const start = useCallback(async () => {
if (!(await Brightness.isAvailableAsync())) return;
dimmingRef.current = true;
await applyDim();
}, [applyDim]);
const stop = useCallback(async () => {
dimmingRef.current = false;
await restore();
originalRef.current = null;
}, [restore]);
useEffect(() => {
const sub = AppState.addEventListener('change', next => {
if (!dimmingRef.current) return;
if (next === 'active') {
applyDim();
} else {
// Give it back on background and inactive. iOS will not do it for you
restore();
}
});
return () => {
sub.remove();
if (dimmingRef.current) restore();
};
}, [applyDim, restore]);
return { start, stop };
}I catch inactive as well, because pulling down Notification Center or opening Control Center on iOS can stop at inactive without ever reaching background. Miss that and you leave a half-dimmed screen behind.
On Android the restore is close to a no-op, and that is fine. Keeping one path is easier to reread months later than maintaining two branches that must stay in sync.
Why I left the Android system functions alone
expo-brightness also ships a group of functions with System in the name, and this is where I hesitated longest.
| Function | Platform | Note |
|---|---|---|
getSystemBrightnessAsync | Android only | Reads the device-wide brightness |
setSystemBrightnessAsync | Android only, experimental | Needs a permission and switches the mode to MANUAL |
restoreSystemBrightnessAsync | Android only | Drops the activity-level override |
setSystemBrightnessModeAsync | Android only | Turns automatic adjustment on or off |
addBrightnessListener | iOS only | Never fires on Android or web |
I wanted Android to dim as deeply as iOS did, and I nearly reached for the system side to get there. But those calls require WRITE_SETTINGS, and setSystemBrightnessAsync rewrites the device brightness and switches the phone to manual mode. An ambient sound app would be silently turning off someone's automatic brightness.
Adding a permission also means explaining that permission in the store listing. For something that only needs to look dim inside my own app, that price was not worth paying.
What I settled on is to keep the effect inside the time the app is in front. Restore it myself on iOS, let the OS restore it on Android. Following each platform's own habit leaves less mess on someone's phone.
Dimming and keeping the screen awake belong together
People dim a screen because they want to keep looking at it while falling asleep. Dimming alone does not stop the OS from turning the display off a little later, which is where the "it disappeared after thirty seconds" reports come from.
So I disable the auto-lock while dimming and release it once the fade has finished. Holding the screen awake will drain a battery, so I decide the release condition before I add the feature, not after.
There is one more piece on iOS: addBrightnessListener. If someone raises the brightness themselves from Control Center, the value you captured is already stale. Re-capturing when the listener fires keeps the restore from feeling wrong. Audio has the same shape of problem, which I wrote up in Three Minutes After the Screen Locked, My expo-audio Ambient Sound Went Silent. Anything that runs after the screen goes dark needs its "after you leave" behavior decided first.
What you can check tonight
With two devices this takes about ten minutes. On the iPhone, tap dim, press home, quit the app, then open Settings and look at the brightness slider. If it is still down, your restore never ran. On Android, the brightness should come back the moment you reach the home screen.
iOS 27 and iPadOS 27 ship on September 14. Anything that writes directly to an OS default, brightness included, is worth exercising once before the update lands; I described how I do that in Put Your Live Rork App on a Spare iPhone Before iOS 27 Ships.
Start with one thing: leave the screen you are currently dimming, and watch what happens. That is where I found mine.