react-native-android-liquid-glass
v0.2.1
Published
iOS 26–style Liquid Glass for React Native on Android — AGSL refraction, glassmorphism, chromatic dispersion, and sensor highlights. Safe cross-platform fallbacks.
Maintainers
Keywords
Readme
react-native-android-liquid-glass
Liquid Glass / glassmorphism for React Native on Android — real AGSL refraction (not a flat blur), chromatic dispersion, adaptive tint, and tilt-driven specular highlights. Built as a Fabric bridge over QWEA0/Liquid-Glass-Android, so you can bring an iOS 26 Liquid Glass look to Android View UIs.
Also searched as: frosted glass, glass blur, backdrop blur, UIGlassEffect for Android, expo-glass-effect Android alternative.
The effect is Android-only and New Architecture only. The package is safe to
import and render everywhere. On iOS, web, macOS, Windows and in Jest the
components degrade to a styled View — a dimming scrim, a correctly clamped
corner radius and an edge hairline — rather than throwing. See
Cross-platform behaviour. For native glass on
iOS 26, pass your own renderFallback backed by UIGlassEffect /
expo-glass-effect.
Why this library
React Native has no built-in Liquid Glass. Most “glass” packages are either
iOS-only (UIGlassEffect / expo-glass-effect) or a simple frosted blur.
This package is for apps that need the Android side of that material:
| You want… | This package |
| --- | --- |
| iOS 26–style liquid glass on Android | ✅ AGSL SDF lens (API 33+) |
| Real refraction / dispersion, not only blur | ✅ lens + classic pipelines |
| Glass that works in lists, nav bars, tab bars | ✅ recipes + ScrollEdgeBlurView |
| Safe import on iOS / web / Jest | ✅ degraded View + renderFallback |
| Expo Go | ❌ needs a dev build / prebuild (native module) |
npm install react-native-android-liquid-glass
# or
yarn add react-native-android-liquid-glassFeatures
- Real AGSL lens on API 33+ — SDF refraction, per-channel dispersion, normal-lit specular highlight, with an automatic CPU/GPU blur fallback down to API 24.
- 42 props across both pipelines, fully typed, with upstream's defaults spelled out so JS and native never drift.
- Liquid shape merging — two glass bodies that smooth-min blend and cling to each other like mercury, driven through Fabric commands.
- Adaptive tint that samples backdrop luminance and tells you when to flip your foreground colour.
- Gravity-driven specular — the rim catches light as the device tilts.
ScrollEdgeBlurView— progressive edge blur for content scrolling under a translucent header.- Frame-time diagnostics through a natively throttled event, so you can tune on a real mid-range device instead of guessing.
- No runtime dependencies beyond React Native itself.
Requirements
| | |
| --- | --- |
| React Native | 0.85, New Architecture (Fabric) |
| minSdk | 24 — the AGSL lens pipeline needs API 33+, below that it falls back |
| compileSdk | 36 |
| ABIs | arm64-v8a, armeabi-v7a only (upstream ships no x86 .so) |
| Upstream | com.github.QWEA0:liquidglass:v2.0.0 |
Installation
npm install react-native-android-liquid-glassThe upstream library is published on JitPack, and android/build.gradle
declares that repository itself. If your app sets
RepositoriesMode.FAIL_ON_PROJECT_REPOS in android/settings.gradle, that
declaration is ignored and you have to add it in the app instead:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" }
}
}To pin a different upstream version, set liquidGlassVersion in your app's
android/build.gradle:
buildscript {
ext {
liquidGlassVersion = "v2.0.0"
}
}The coordinate in the upstream README (
com.github.QWEA0.Liquid-Glass-Android:liquidglass) does not resolve — the module overrides its owngroupId. The working one iscom.github.QWEA0:liquidglass, which is what this package uses.
Quick start
import { Text, View } from 'react-native';
import { LiquidGlassAndroidView } from 'react-native-android-liquid-glass';
<View collapsable={false} style={{ flex: 1 }}>
{/* whatever should show through the glass */}
<YourContent />
<LiquidGlassAndroidView
style={{ width: 260, height: 140 }}
cornerRadius={28}
material="regular"
refractionHeight={66}
enableDynamicBackground
>
<Text>Liquid Glass</Text>
</LiquidGlassAndroidView>
</View>;The component is a FrameLayout natively, so children render on top of the
glass — put your content inside it.
Three rules that decide whether this works
Almost every "the effect doesn't show up" case is one of these.
1. The backdrop must be in the glass's immediate parent
Upstream captures the backdrop with view.parent as? View. It draws that one
parent's subtree, and nothing above it. So:
- Content you want refracted has to be a sibling of the glass, under the same parent. A sibling of the parent is invisible to the capture.
- Anything drawn after the glass in that same parent lands in the capture too. Put overlays and controls outside the parent, or they appear smeared inside the glass.
- Add
collapsable={false}to that parent. React Native flattens away views it considers redundant, which silently re-parents the glass and changes what it captures.
2. enableDynamicBackground={false} if nothing moves
This defaults to true, which is not upstream's field default. Nothing in
React Native invalidates the glass when a sibling repaints, so with it off the
backdrop is captured once and then frozen — scrolling content slides past while
the glass keeps showing a stale image, which reads as a broken effect rather than
a saving.
It is not free: upstream implements it by calling invalidate() at the end of
every draw, so it is an unbounded redraw loop at display refresh rate plus a
backdrop re-capture per frame, for as long as the view is mounted. Turn it off
for genuinely static glass over static content, and reach for paused when a
view stays mounted but stops being visible.
3. The backdrop needs high-frequency detail
Refraction bends the backdrop. A flat colour bent is still that flat colour, and
a smooth gradient barely changes. Over solid backgrounds the effect is invisible
and the library looks broken. The example app ships flat and gradient
backdrops specifically so you can see this failure mode.
Props
Every dimension marked dp is converted to px natively. Props upstream
declares as unitless floats — including displacementScale and
dispersionThickness, which are map-space scalars rather than screen distances —
are passed straight through.
Lens pipeline (API 33+)
| Prop | Type | Default |
| --- | --- | --- |
| material | 'regular' \| 'clear' | 'regular' |
| cornerRadius | number (dp) | 999 (pill) |
| bevelWidth | number (dp) | 14 |
| refractionHeight | number (dp) | 66 |
| dispersionStrength | number 0–1 | 0.1 |
| enableSensorHighlight | boolean | false |
| enableAdaptiveTint | boolean | false |
| useShaderPipeline | boolean | true |
refractionHeight is the dominant knob — at 0 the glass flattens into a plain
blur. material="clear" blurs far less and applies a fixed dimming layer instead
of adaptive tint; it is for glass over photos and video.
Blur
| Prop | Type | Default |
| --- | --- | --- |
| enableDynamicBackground | boolean | true |
| enableBackdropBlur | boolean | true |
| paused | boolean | false |
| layerType | 'hardware' \| 'none' | 'hardware' |
| blurScale | number (unitless multiplier) | 0.0625 |
| blurMethod | 'smart' \| 'boxBlur' \| 'boxBlurCpp' \| 'iirGaussian' \| 'iirGaussianNeon' \| 'downsample' \| 'box3' | 'smart' |
| highQualityBlur | boolean | false |
| downsampleScale | int, 2–3 | 2 |
| globalDownsampleFactor | number 0.25–1 | 1 |
| useHardwareBlurWhenPossible | boolean | true |
| enableOptimizedCapture | boolean | false |
blurScale is a unitless multiplier: the effective radius is
(overLight ? 12 : 4) + blurScale * 32 pixels. It is not
@react-native-community/blur's 0–100 pixel radius — copying blurAmount={20}
from that library gives a 644 px radius here, and __DEV__ warns about it.
(blurAmount is the old name for this prop and still works for one minor
version.)
downsampleScale is clamped by upstream to 2–3, so raising it further is not
a tuning lever. Use globalDownsampleFactor instead.
paused stops the enableDynamicBackground redraw loop without unmounting —
set it whenever the view is offscreen but still mounted, such as behind a pushed
navigation screen or on an inactive tab.
Chromatic aberration
| Prop | Type | Default |
| --- | --- | --- |
| enableChromaticAberration | boolean | true |
| aberrationIntensity | number | 2 |
| aberrationDownsample | number 0.25–1 | 0.5 |
| aberrationRedOffset | number | 0 |
| aberrationGreenOffset | number | -0.05 |
| aberrationBlueOffset | number | -0.1 |
| aberrationUseBilinearInterpolation | boolean | true |
| chromaticAberrationMode | 'auto' \| 'cpp' \| 'kotlin' | 'auto' |
Physical dispersion
| Prop | Type | Default |
| --- | --- | --- |
| enableChromaticDispersion | boolean | false |
| dispersionThickness | number (unitless) | 100 |
| dispersionFactor | number | 1.5 |
| dispersionGain | number | 7 |
| dispersionDownsample | number 0.25–1 | 0.5 |
The most expensive effect in the library. Measure it before shipping it, and never put it in a list.
Edges, shadow, tone
| Prop | Type | Default |
| --- | --- | --- |
| enableEdgeHighlight | boolean | true |
| edgeHighlightBorderWidth | number (dp) | 0.5 |
| edgeHighlightOpacity | number 0–100 (percent) | 100 |
| enableShadow | boolean | false |
| saturation | number (percent, 100 = unchanged) | 140 |
| overLight | boolean | false |
edgeHighlightOpacity and saturation are percentages, not the 0–1 React
Native uses for opacity. Upstream's units are kept rather than silently
rescaled.
Displacement & touch
| Prop | Type | Default |
| --- | --- | --- |
| displacementScale | number (unitless) | 70 |
| displacementMode | 'standard' \| 'polar' \| 'prominent' | 'standard' |
| elasticity | number | 0.15 |
Accessibility & diagnostics
| Prop | Type | Default |
| --- | --- | --- |
| accessibilityMode | 'auto' \| 'forceFull' \| 'forceOpaque' | 'auto' |
| collectFrameStats | boolean | true |
| frameStatsInterval | int, ms (0 = off) | 0 |
auto degrades to an opaque material when the system asks for reduced
transparency or high-contrast text. forceFull overrides a user's explicit
accessibility preference — reach for it only when your own layer already
guarantees contrast.
Events
<LiquidGlassAndroidView
enableAdaptiveTint
onGlassAppearanceChange={(e) => setDark(!e.nativeEvent.isOverLight)}
onPipelineReady={(e) => console.log(e.nativeEvent.deviceApiLevel)}
frameStatsInterval={250}
onFrameStats={(e) => setFps(e.nativeEvent.drawFps)}
/>| Event | Payload | Notes |
| --- | --- | --- |
| onGlassAppearanceChange | { isOverLight } | Fires when backdrop luminance crosses the light/dark threshold, with hysteresis. Needs enableAdaptiveTint. |
| onPipelineReady | { deviceApiLevel, supportsLensPipeline } | One-shot, after the first prop commit. |
| onFrameStats | { drawFps, totalMs, captureMs, blurMs, effectMs, finalizeMs, effectName, blurRecomputed, effectRecomputed, processedWidth, processedHeight } | Requires collectFrameStats and frameStatsInterval > 0. |
onFrameStats is throttled natively. Upstream invokes its listener on every drawn
frame; forwarding that across the bridge at 60fps would cost more than the effect,
so frameStatsInterval={0} (the default) never attaches a JS-visible listener at
all. It is a development HUD — ship it off.
supportsLensPipeline is a device capability check, not a promise about the frame
you are looking at: useShaderPipeline={false} or an accessibility degradation
still force the classic path on a capable device.
Imperative API
Shapes are commands, not props. Grab a ref:
import { useEffect, useRef } from 'react';
import type { LiquidGlassAndroidViewHandle } from 'react-native-android-liquid-glass';
const glass = useRef<LiquidGlassAndroidViewHandle>(null);
useEffect(() => {
// Two glass bodies that smooth-min blend — close the gap and they merge.
glass.current?.setPrimaryShape({
x: 20, y: 40, width: 96, height: 96, cornerRadius: 48,
});
glass.current?.setSecondaryShape({
x: 150, y: 40, width: 96, height: 96, cornerRadius: 48, smoothing: 16,
});
}, []);
<LiquidGlassAndroidView ref={glass} style={{ width: 300, height: 190 }} />;| Method | Notes |
| --- | --- |
| setPrimaryShape(shape) | Glass geometry inside the view, dp, relative to its top-left. Lens pipeline only. |
| clearPrimaryShape() | Back to filling the whole view. |
| setSecondaryShape(shape) | A second body, smooth-min blended with the first. |
| clearSecondaryShape() | Remove it. |
| refreshAccessibilityState() | Re-read system settings, e.g. from an AppState active handler after the user returns from Settings. |
Omitting cornerRadius reproduces upstream's defaults: the view's current
cornerRadius for the primary shape, a pill for the secondary.
ScrollEdgeBlurView
The library's second component: a progressive blur pinned to one edge, so content
dissolves as it scrolls under a header. It is a plain View, takes no children,
and should be positioned absolutely.
import { ScrollEdgeBlurView } from 'react-native-android-liquid-glass';
<ScrollEdgeBlurView
pointerEvents="none"
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 90 }}
edge="top"
maxBlurRadius={40}
/>;| Prop | Type | Default |
| --- | --- | --- |
| edge | 'top' \| 'bottom' | 'top' |
| maxBlurRadius | number, raw px | 40 |
maxBlurRadius is the one prop that is not converted from dp: upstream clamps it
to 0–100 pixels, so converting would cut the usable range to about 33dp on a 3x
screen. Scale it with PixelRatio.get() yourself if you want it
density-independent.
Its handle exposes bindScrollView(ref) / unbindScrollView(). In practice
ViewTreeObserver scroll callbacks are window-wide so it usually redraws either
way, but binding documents the intent.
Cross-platform behaviour
Importing and rendering this package is safe on every platform. On anything
other than Android both components render a degraded View:
| Android prop | Fallback mapping |
| --- | --- |
| cornerRadius | borderRadius, clamped to half the shorter side, borderCurve: 'continuous' |
| material, overLight | a dimming backgroundColor scrim |
| enableEdgeHighlight, edgeHighlightBorderWidth, edgeHighlightOpacity | borderWidth + borderColor |
| enableShadow | shadow* |
| everything blur/refraction/dispersion related | dropped |
The scrim is not decoration. On Android the regular material dims the backdrop,
which is what makes white-on-glass text readable — a fully transparent fallback
would put white text on a white background with no crash and no warning. The
clamp is not polish either: an unclamped borderRadius: 999 renders as a
square on iOS whenever a border is present.
onPipelineReady fires once on the fallback with supportsLensPipeline: false,
so a capability gate resolves instead of hanging. The imperative handle is
complete, with isSupported: false and commands that return false rather than
throwing.
Branching without rendering
import { getGlassCapabilities, useGlassSupport } from 'react-native-android-liquid-glass';
const { supported, supportsLensPipeline } = useGlassSupport();Supplying your own fallback
<LiquidGlassAndroidView
style={styles.panel}
renderFallback={({ style, children }) => (
<GlassView style={style}>{children}</GlassView> // e.g. expo-glass-effect
)}
>
<Text>Now playing</Text>
</LiquidGlassAndroidView>This keeps expo-glass-effect out of this package's dependency graph entirely.
Error handling
Both components take an onError handler. It fires when a prop value is
rejected, an enum string is unrecognised, a command throws natively, or a
capability had to be demoted for the device:
<LiquidGlassAndroidView
onError={({ nativeEvent: { code, prop, message, fatal } }) => {
if (fatal) setUseGlass(false);
else console.warn(`[glass] ${code} on ${prop}: ${message}`);
}}
/>Codes: NATIVE_BLUR_UNAVAILABLE, PIPELINE_DEGRADED, INVALID_ENUM,
INVALID_VALUE, COMMAND_FAILED, BIND_FAILED,
EVENT_DISPATCH_UNAVAILABLE. Most are fatal: false — the bridge recovered —
but they always mean the view is not doing what the props asked for. Everything
is logged to logcat under the LiquidGlassAndroidView tag even with no handler.
In __DEV__ a JS validation layer additionally warns (once per prop) about the
things upstream would silently swallow: non-finite numbers, the percentage props
that look like 0–1 ranges, values outside upstream's silent clamps, and prop
combinations that can never fire.
Presets
import { LiquidGlassAndroidView, GlassPresets } from 'react-native-android-liquid-glass';
<LiquidGlassAndroidView preset="floatingTabBar" cornerRadius={32} />preset takes navigationBar, floatingTabBar, cardOverMedia or
compactControl; dispersionPreset takes subtle, glass, crystal,
diamond or rainbow. Both are resolved in JS as
{ ...preset, ...yourProps }, so an explicit prop always wins. The raw maps are
exported as GlassPresets and DISPERSION_PRESETS.
Performance
The effect captures and re-blurs the backdrop. Rough order of what to reach for:
paused={true}whenever the view is mounted but not visible. This is the biggest single win:enableDynamicBackgrounddrives a self-invalidating redraw at display refresh rate that nothing else stops, including behind a pushed navigation screen.globalDownsampleFactorto0.5— quarters the pixels the capture and blur touch, and the blur hides most of the resolution loss.enableDynamicBackground={false}wherever nothing actually moves.enableChromaticDispersion={false}— by far the heaviest effect.aberrationDownsample/dispersionDownsampledown. (downsampleScaleis clamped to 2–3 upstream, so it is not the lever it looks like.)- At most one
enableAdaptiveTintview per screen. It costs a periodic softwareView.draw()of the glass's parent subtree on the UI thread every 350 ms, on every pipeline. Drive the rest from itsonGlassAppearanceChange. layerType="none"if you have large or numerous glass views — upstream forces a hardware layer, which is an off-screen texture per instance. Measure it.
Pass StyleSheet.created styles and useCallback-wrapped handlers: both
components are wrapped in memo, and inline styles or arrow handlers defeat the
bailout, forcing a full native prop re-commit on every ancestor render.
Do not stack many instances in a scrolling list. Keep the frame-stats demo open on
a real mid-range device while tuning, and read maxTotalMs rather than totalMs
— it covers every frame in the window, so spikes are visible.
Known limitations
- Touches mostly stop at the glass.
pointerEvents="none"/"box-none"now stops the glass claiming touches at the React Native level, which is what you want for a decorative panel. It cannot be complete: upstreamv2.0.0'sonTouchEventreturnstruefor every down/move/up with no opt-out, and Android dispatches to a disabled view'sonTouchEventanyway.elasticity={0}does not disable the capture. Upstreammain'senablePressEffectis the real fix and will be adopted when released. - The native blur kernels are arm only. The AAR ships
libnativegauss.soforarm64-v8aandarmeabi-v7aonly, and the failure surfaces asExceptionInInitializerError/NoClassDefFoundError—java.lang.Errors that upstream's owncatch (Exception)cannot stop. The bridge probes for the library at view creation and demotesblurMethodtoboxBlur,chromaticAberrationModetokotlin, and forcesenableChromaticDispersionoff, reportingNATIVE_BLUR_UNAVAILABLEthroughonError. So an x86_64 emulator renders a softer effect instead of crashing — but use an arm64 emulator (the default on Apple Silicon) or a device to see the real thing. - Props for the inactive pipeline are ignored. Nothing throws on API 30 — you
just get a blur where a lens was designed.
__DEV__warns for the cases where this is most surprising (primaryShape,enableChromaticDispersion). - Upstream
mainis ahead ofv2.0.0.enablePressEffect,pressScale,debugApiLevelCapandeffectiveApiLevelexist onmainbut not in the released artifact, so they are deliberately not bridged: the module would not compile against the version people can actually install. setCustomBackdropCaptureis not exposed. It takes a Kotlin lambda returning aBitmapand has no sensible JS equivalent.
Example app
17 demos covering the whole bridged surface — a home-screen showcase, a
playground per prop group, the shape commands, all three events, and four
copy-paste UI recipes. Screenshots live in docs/media/.
yarn # install
yarn example start # metro
yarn android # build + runRun on an arm64 emulator or a physical device.
Development
yarn typecheck # tsc
yarn lint # eslint
yarn codegen # regenerate the Fabric interface/delegate to read themAdding a prop is two steps, and the compiler enforces the second:
- Declare it in
src/LiquidGlassAndroidViewNativeComponent.ts. - Implement the generated
override fun setXxx(...)inandroid/src/main/java/com/liquidglassandroid/LiquidGlassAndroidViewManager.kt.
See docs/BRIDGING.md for the full mechanics.
Contributing
License
MIT. The upstream Liquid-Glass-Android library is also MIT.
Made with create-react-native-library
