Brightness
@symbiote-native/brightness wraps expo-brightness
so every SymbioteNative adapter can read and set the screen brightness. Like battery
and cellular, it’s built on expo-modules-core — a pure async-function +
EventEmitter surface, no Fabric view involved. Its permission surface (getPermissionsAsync/
requestPermissionsAsync) shares the exact same usePermissions() hook/composable/service shape as
@symbiote-native/cellular, so switching between the two packages needs no relearning; what’s unique to
brightness is an Android-only system-brightness-mode surface and an iOS-only change listener.
| OS platform | Support |
|---|---|
| iOS | ✅ live |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
| Solid | ✅ live |
Installation
Section titled “Installation”npm install @symbiote-native/brightnessScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --brightness (or
add --brightness in an existing app) installs and wires this for you — see
@symbiote-native/cli.
expo-brightness and expo-modules-core come along as regular dependencies, pinned to exact
versions — never install either yourself, and never add the expo meta-package to your project
(it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).
Reading and setting brightness
Section titled “Reading and setting brightness”There is no live-value hook for brightness itself (unlike battery’s
useBatteryLevel) — seed from getBrightnessAsync() on mount, then subscribe to
addBrightnessListener for live updates. The listener only ever fires on iOS; on Android the value
only changes in response to your own setBrightnessAsync calls.
import { useEffect, useState } from 'react';import { addBrightnessListener, getBrightnessAsync, setBrightnessAsync } from '@symbiote-native/brightness';
export default function BrightnessControl() { const [brightness, setBrightness] = useState<number | null>(null);
useEffect(() => { getBrightnessAsync().then(setBrightness); const subscription = addBrightnessListener(event => setBrightness(event.brightness)); return () => subscription.remove(); }, []);
return ( <view> <text>{brightness === null ? 'checking…' : `${Math.round(brightness * 100)}%`}</text> <pressable onPress={() => setBrightnessAsync(0.5)}> <text>Set to 50%</text> </pressable> </view> );}<script setup lang="ts">import { onMounted, onUnmounted, ref } from 'vue';import { addBrightnessListener, getBrightnessAsync, setBrightnessAsync } from '@symbiote-native/brightness';import type { EventSubscription } from '@symbiote-native/brightness';
const brightness = ref<number | null>(null);let subscription: EventSubscription | undefined;
onMounted(() => { void getBrightnessAsync().then(value => (brightness.value = value)); subscription = addBrightnessListener(event => (brightness.value = event.brightness));});
onUnmounted(() => subscription?.remove());</script>
<template> <view> <text>{{ brightness === null ? 'checking…' : `${Math.round(brightness * 100)}%` }}</text> <pressable @press="setBrightnessAsync(0.5)"> <text>Set to 50%</text> </pressable> </view></template>import { Component, OnDestroy, signal } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { addBrightnessListener, getBrightnessAsync, setBrightnessAsync } from '@symbiote-native/brightness';import type { EventSubscription } from '@symbiote-native/brightness';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: ` <view> <text>{{ brightness() === null ? 'checking…' : (brightness()! * 100 | number: '1.0-0') + '%' }}</text> <pressable (press)="setBrightnessAsync(0.5)"> <text>Set to 50%</text> </pressable> </view> `,})export class BrightnessControl implements OnDestroy { readonly brightness = signal<number | null>(null); private readonly subscription: EventSubscription;
constructor() { getBrightnessAsync().then(value => this.brightness.set(value)); this.subscription = addBrightnessListener(event => this.brightness.set(event.brightness)); }
ngOnDestroy(): void { this.subscription.remove(); }}<script lang="ts"> import { addBrightnessListener, getBrightnessAsync, setBrightnessAsync } from '@symbiote-native/brightness'; import type { EventSubscription } from '@symbiote-native/brightness';
let brightness = $state<number | null>(null);
$effect(() => { getBrightnessAsync().then(value => (brightness = value)); const subscription: EventSubscription = addBrightnessListener(event => (brightness = event.brightness)); return () => subscription.remove(); });</script>
<view> <text>{brightness === null ? 'checking…' : `${Math.round(brightness * 100)}%`}</text> <pressable onPress={() => setBrightnessAsync(0.5)}> <text>Set to 50%</text> </pressable></view>import { createSignal, onCleanup } from 'solid-js';import { addBrightnessListener, getBrightnessAsync, setBrightnessAsync } from '@symbiote-native/brightness';import type { EventSubscription } from '@symbiote-native/brightness';
export function BrightnessControl() { const [brightness, setBrightness] = createSignal<number | null>(null);
getBrightnessAsync().then(setBrightness); const subscription: EventSubscription = addBrightnessListener(event => setBrightness(event.brightness)); onCleanup(() => subscription.remove());
return ( <view> <text>{brightness() === null ? 'checking…' : `${Math.round(brightness()! * 100)}%`}</text> <pressable onPress={() => setBrightnessAsync(0.5)}> <text>Set to 50%</text> </pressable> </view> );}The seed fetch and the subscription both fire directly in the component body, not from
onMount: a Solid body runs once, so this already happens at construction time. onCleanup
tears the subscription down when the owner disposes.
Permission
Section titled “Permission”import { usePermissions } from '@symbiote-native/brightness/react';
export default function BrightnessPermission() { const [status, request, get, error] = usePermissions();
return ( <> <text>{error ? `check failed: ${error.message}` : (status?.status ?? 'checking…')}</text> <pressable onPress={() => (error ? get() : request())}> <text>{error ? 'Retry check' : 'Request permission'}</text> </pressable> </> );}<script setup lang="ts">import { usePermissions } from '@symbiote-native/brightness/vue';
const { status, error, request, get } = usePermissions();</script>
<template> <text>{{ error ? `check failed: ${error.message}` : (status?.status ?? 'checking…') }}</text> <pressable @press="error ? get() : request()"> <text>{{ error ? 'Retry check' : 'Request permission' }}</text> </pressable></template>import { Component, inject } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { PermissionsService } from '@symbiote-native/brightness/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: ` <text>{{ error() ? 'check failed: ' + error()?.message : (status()?.status ?? 'checking…') }}</text> <pressable (press)="error() ? permissions.get() : permissions.request()"> <text>{{ error() ? 'Retry check' : 'Request permission' }}</text> </pressable> `,})export class BrightnessPermission { readonly permissions = inject(PermissionsService); readonly status = this.permissions.connect(); readonly error = this.permissions.error;}<script lang="ts"> import { usePermissions } from '@symbiote-native/brightness/svelte';
const permissions = usePermissions();</script>
<text> {permissions.error ? `check failed: ${permissions.error.message}` : (permissions.status?.status ?? 'checking…')}</text><pressable onPress={() => (permissions.error ? permissions.get() : permissions.request())}> <text>{permissions.error ? 'Retry check' : 'Request permission'}</text></pressable>import { createPermissions } from '@symbiote-native/brightness/solid';
export function BrightnessPermission() { const { status, error, request, get } = createPermissions();
return ( <> <text>{error() ? `check failed: ${error()!.message}` : (status()?.status ?? 'checking…')}</text> <pressable onPress={() => (error() ? get() : request())}> <text>{error() ? 'Retry check' : 'Request permission'}</text> </pressable> </> );}status stays null both while the first fetch is in flight and after it fails, so error is what
tells those two apart; get() re-runs the check without prompting the user.
Functions
Section titled “Functions”| Signature | Description |
|---|---|
isAvailableAsync(): Promise<boolean> |
Whether getBrightnessAsync/setBrightnessAsync exist on the native module |
getBrightnessAsync(): Promise<number> |
Current screen brightness between 0 and 1, inclusive |
setBrightnessAsync(value: number): Promise<void> |
Sets the screen brightness (0..1, clamped); iOS only affects the app while foregrounded, Android persists until changed again |
getSystemBrightnessAsync(): Promise<number> |
Gets the system-wide brightness. Delegates to getBrightnessAsync on every platform except Android, since iOS has no separate system-level value |
setSystemBrightnessAsync(value: number): Promise<void> |
Sets the system-wide brightness. Delegates to setBrightnessAsync on every platform except Android |
restoreSystemBrightnessAsync(): Promise<void> |
Resets the system brightness to the value it had before this app started controlling it. No-op on every platform except Android |
isUsingSystemBrightnessAsync(): Promise<boolean> |
Whether the activity’s window has no brightness override of its own, so the system-wide value is what the screen shows. It cannot distinguish “the app set the system value” from “the app never touched brightness” — it reports only that setBrightnessAsync is not overriding this window. Always false except on Android |
getSystemBrightnessModeAsync(): Promise<BrightnessMode> |
Gets the system brightness mode. Always resolves BrightnessMode.UNKNOWN except on Android |
setSystemBrightnessModeAsync(mode: BrightnessMode): Promise<void> |
Sets the system brightness mode. No-op except on Android, and also a no-op when passed BrightnessMode.UNKNOWN |
getPermissionsAsync(): Promise<PermissionResponse> |
Checks the user’s permission for accessing the system brightness |
requestPermissionsAsync(): Promise<PermissionResponse> |
Asks the user for permission to access the system brightness |
addBrightnessListener(listener): EventSubscription |
Subscribes to brightness-change events. Only fires on iOS — never on Android; call .remove() on the returned subscription to unsubscribe |
usePermissions()
Section titled “usePermissions()”React (/react) |
Vue (/vue) |
Angular (/angular) |
Svelte (/svelte) |
Solid (/solid) |
Signature | Returns |
|---|---|---|---|---|---|---|
usePermissions |
usePermissions |
PermissionsService.connect() |
usePermissions |
createPermissions |
() |
React: [PermissionResponse | null, request, get, Error | null] tuple. Vue: { status: Ref<PermissionResponse | null>, error: Ref<Error | null>, request, get }. Angular: Signal<PermissionResponse | null> from connect(), plus error: Signal<Error | null> and request()/get() on the service. Svelte: { status, error, request, get }, status and error getters over PermissionResponse | null / Error | null. Solid: { status: Accessor<PermissionResponse | null>, error: Accessor<Error | null>, request, get } |
usePermissions() return value
Section titled “usePermissions() return value”| Field | Type | Description |
|---|---|---|
status |
PermissionResponse | null |
The permission status last read, null until the first fetch resolves |
error |
Error | null |
Why the automatic fetch left status at null. Cleared by the next successful get()/request() |
request |
() => Promise<PermissionResponse> |
Asks the user for the permission, then updates status and clears error. Rejects to its caller when the native call fails |
get |
() => Promise<PermissionResponse> |
Re-reads the current status without prompting. Same update and rejection behavior as request |
React hands those back positionally, as [status, request, get, error], so existing two- and
three-element destructuring keeps working. Vue wraps status and error in refs; Angular exposes
status through connect() and error as a separate readonly signal on the service; Svelte
returns both as getters, read as permissions.status / permissions.error.
Read together, the two fields separate the three states:
status |
error |
Meaning |
|---|---|---|
null |
null |
Not fetched yet |
null |
Error |
The automatic fetch failed |
PermissionResponse |
null |
Fetched |
Every variant auto-fetches the current permission status once on mount/connect(), and updates
again whenever request/get resolves. A failure in that automatic fetch lands in error instead
of escaping as an unhandled rejection; get()/request() called by hand still reject to their
caller. Angular’s auto-fetch is latched to at most one run per service instance, so a later
connect() never re-fetches: call get() to retry.
BrightnessMode
Section titled “BrightnessMode”| Member | Value | Description |
|---|---|---|
UNKNOWN |
0 |
Returned when the brightness mode cannot be determined |
AUTOMATIC |
1 |
Automatic brightness mode, tracking the ambient light sensor |
MANUAL |
2 |
Manual brightness mode, set by the user or by setSystemBrightnessAsync |
BrightnessEvent
Section titled “BrightnessEvent”| Field | Type | Description |
|---|---|---|
brightness |
number |
The current brightness value between 0 and 1, inclusive |
setSystemBrightnessAsyncswitches the device out of adaptive brightness. Before writing the value, the Android module putsSCREEN_BRIGHTNESS_MODEintoMANUAL— the device stays manual afterwards until something sets the mode back, which is whatsetSystemBrightnessModeAsyncis for.- The
WRITE_SETTINGSgrant is re-checked on every system write.setSystemBrightnessAsyncandsetSystemBrightnessModeAsynccallSettings.System.canWriteeach time and throw a permissions exception when it isfalse— a grant revoked after the fact surfaces as a rejected promise, not as aPermissionResponse, so keep acatchon those two even oncerequestPermissionsAsynchas resolved granted. - Android quantizes the system value.
0..1is mapped onto Android’s integer1..255range on write and back on read, so a written brightness round-trips approximately, never exactly. While the device is inAUTOMATICmode,getSystemBrightnessAsyncreads the auto-brightness adjustment setting instead, rescaled — not the brightness actually on screen. restoreSystemBrightnessAsyncclears an override rather than replaying a saved value. It puts the activity window’s brightness back toBRIGHTNESS_OVERRIDE_NONE, handing the screen back to the system setting; nothing this app wrote earlier is restored. The same override is whygetBrightnessAsyncreports the system brightness until your firstsetBrightnessAsynccall installs one.
Common questions
Section titled “Common questions”- My brightness change disappears on iOS.
setBrightnessAsyncpersists only until the device locks, then the user’s setting returns. - On Android it only works while my app is open. The value applies to the current activity and
overrides the system brightness while the app is in the foreground. Call
restoreSystemBrightnessAsyncto go back to the system value. - How do I change the system brightness on Android?
setSystemBrightnessAsync(experimental) needs theWRITE_SETTINGSpermission, granted by the user in system settings. - Permission result is empty after granting
WRITE_SETTINGS. Reported upstream; re-query the permission after returning from settings. - Brightness 0 on Android. Fixed in upstream 55.0.5 by mapping
[0, 1]onto Android’s[1, 255].
Sources: Expo docs: Brightness, expo/expo#18967, expo-brightness changelog.
How the wrapper works
Section titled “How the wrapper works”@symbiote-native/brightness ships zero React/Vue/Angular/Svelte/Solid logic in expo-brightness itself —
that package’s own JS hard-imports PermissionResponse/PermissionStatus from the expo
meta-package (which this project never installs), so its functions and types are hand-ported,
verbatim, into this package’s own core/, changing only that one import line to pull from
expo-modules-core instead:
packages/brightness/src/├── core/ isAvailableAsync/getBrightnessAsync/setBrightnessAsync + the│ Android system-brightness surface + getPermissionsAsync/│ requestPermissionsAsync + addBrightnessListener; native-module.ts│ resolves the single `ExpoBrightness` native module via│ expo-modules-core's requireNativeModule├── react/hooks/use-permissions @symbiote-native/brightness/react├── vue/composables/use-permissions @symbiote-native/brightness/vue├── svelte/runes/use-permissions @symbiote-native/brightness/svelte├── solid/primitives/use-permissions @symbiote-native/brightness/solid└── angular/services/permissions.service @symbiote-native/brightness/angularusePermissions is the only stateful surface — auto-fetch on mount, expose get/request as
imperative callbacks — written once as a shared pattern and reapplied identically in
cellular; every other export is a stateless free
function, re-exported verbatim by all five adapters. The native code itself is never vendored or
copied — expo-modules-autolinking resolves it straight out of node_modules (see the native
setup guide).