Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Shown in GitHub's "Sponsor this project" sidebar
github: himanshu-lal4
buy_me_a_coffee: wrack
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# react-native-liquid-glassmorphism
# React Native Liquid Glass — for Android AND iOS

<p align="center"><code>react-native-liquid-glassmorphism</code></p>

<p align="center">
<a href="https://www.npmjs.com/package/react-native-liquid-glassmorphism"><img src="https://img.shields.io/npm/v/react-native-liquid-glassmorphism?color=cb3837&logo=npm&logoColor=white" alt="npm version" /></a>
Expand All @@ -16,6 +18,14 @@
📦 <b><a href="https://www.npmjs.com/package/react-native-liquid-glassmorphism">Install from npm</a></b>
</p>

<p align="center">
<a href="https://github.com/sponsors/himanshu-lal4"><picture><source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/GitHub_Sponsors-21262D?style=for-the-badge&logo=githubsponsors&logoColor=EA4AAA"><img src="https://img.shields.io/badge/GitHub_Sponsors-EAEEF2?style=for-the-badge&logo=githubsponsors&logoColor=EA4AAA" height="32" alt="Sponsor on GitHub" /></picture></a>
&nbsp;
<a href="https://buymeacoffee.com/wrack"><picture><source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/Buy_Me_a_Coffee-21262D?style=for-the-badge&logo=buymeacoffee&logoColor=FFDD00"><img src="https://img.shields.io/badge/Buy_Me_a_Coffee-EAEEF2?style=for-the-badge&logo=buymeacoffee&logoColor=FFDD00" height="32" alt="Buy Me a Coffee" /></picture></a>
</p>

<p align="center"><sub>If this library saves you a native module, a ⭐ and a coffee keep it maintained.</sub></p>

<p align="center">
<b>🤖 Works on Android too — not an iOS-only wrapper. 🍏</b><br/>
<sub>Most React Native Liquid Glass libraries are iOS-only. This one renders real glass on <b>both</b> platforms from the same component.</sub>
Expand Down Expand Up @@ -742,6 +752,10 @@ Yes — with a development build or `expo prebuild`. It does **not** run in Expo

Yes. It's a native Fabric component (codegen), and the same delegate covers the old architecture too.

### How is it different from react-native-liquid-glass (Expo) or @callstack/liquid-glass?

Those are thin wrappers over Apple's `UIGlassEffect`, so they are **iOS-26-only** — on Android they render nothing glass-like. This library drives the same native `UIGlassEffect` on iOS **and** reproduces the optics on Android 13+ with a real-time AGSL refraction shader, from one `<LiquidGlassView>`. It also supports [glass shape **merging**](https://himanshu-lal4.github.io/react-native-liquid-glassmorphism/react-native-glass-merging/) — neighbouring glass views fusing like liquid — which the wrappers don't expose. If your app is iOS-only and stock, the wrappers are fine; if you ship to Android too, that's the gap this library exists to close.

### How is it different from expo-blur / react-native-blur?

Those only **blur** the backdrop. This one blurs **and refracts** it (edge lensing, chromatic dispersion, Fresnel rim, interactive specular), plus native `UIGlassEffect` on iOS 26. See the [comparison](#how-is-it-different-from-a-blur-view).
Expand Down
21 changes: 18 additions & 3 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { CurvedDock, DOCK_H, createDockPath } from './CurvedDock';
import DemoReel from './DemoReel';
import CardGallery from './CardGallery';
import GlassTabBar from './GlassTabBar';
import MercuryDemo from './MercuryDemo';
import MergingOrbs from './MergingOrbs';
import StressTest from './StressTest';
Expand Down Expand Up @@ -111,9 +112,9 @@ function starPoints(
* chip goes back.
*/
export default function App() {
const [mode, setMode] = useState<'reel' | 'gallery' | 'cards' | 'mercury' | 'stress'>(
'reel'
);
const [mode, setMode] = useState<
'reel' | 'gallery' | 'cards' | 'mercury' | 'stress' | 'tabbar'
>('reel');
return (
<SafeAreaProvider>
<View style={styles.root}>
Expand All @@ -122,6 +123,8 @@ export default function App() {
<DemoReel onExit={() => setMode('gallery')} />
) : mode === 'cards' ? (
<CardGallery onBack={() => setMode('gallery')} />
) : mode === 'tabbar' ? (
<GlassTabBar onBack={() => setMode('gallery')} />
) : mode === 'mercury' ? (
<MercuryDemo onBack={() => setMode('gallery')} />
) : mode === 'stress' ? (
Expand All @@ -130,6 +133,7 @@ export default function App() {
<Gallery
onShowReel={() => setMode('reel')}
onShowCards={() => setMode('cards')}
onShowTabBar={() => setMode('tabbar')}
onShowMercury={() => setMode('mercury')}
onShowStress={() => setMode('stress')}
/>
Expand All @@ -145,11 +149,13 @@ export default function App() {
function Gallery({
onShowReel,
onShowCards,
onShowTabBar,
onShowMercury,
onShowStress,
}: {
onShowReel: () => void;
onShowCards: () => void;
onShowTabBar: () => void;
onShowMercury: () => void;
onShowStress: () => void;
}) {
Expand Down Expand Up @@ -177,6 +183,15 @@ function Gallery({
<Text style={styles.demoChipText}>▦ Cards</Text>
</LiquidGlassView>
</Pressable>
<Pressable onPress={onShowTabBar}>
<LiquidGlassView
tintColor="rgba(255,255,255,0.18)"
borderRadius={16}
style={styles.demoChip}
>
<Text style={styles.demoChipText}>▬ Tab bar</Text>
</LiquidGlassView>
</Pressable>
<Pressable onPress={onShowStress}>
<LiquidGlassView
tintColor="rgba(255,255,255,0.18)"
Expand Down
145 changes: 145 additions & 0 deletions example/src/GlassTabBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LiquidGlassView } from 'react-native-liquid-glassmorphism';

// ─────────────────────────────────────────────────────────────────────────────
// GLASS TAB BAR — the single most requested glass use case: a detached,
// fully-rounded tab bar floating above scrolling content, built from
// `<LiquidGlassView preset="floatingTabBar">`. Mirrors what
// docs/react-native-glass-tab-bar.md describes, so the documented recipe is
// exercised on real devices whenever anyone runs the demo. (Issue #88.)
//
// Opened from the API gallery ("▬ Tab bar" chip); the header's back arrow
// returns.
// ─────────────────────────────────────────────────────────────────────────────

const GRADIENT = ['#0f2027', '#2c5364', '#f5af19', '#f12711'] as const;

const TABS = [
{ key: 'home', icon: '⌂', label: 'Home' },
{ key: 'search', icon: '⌕', label: 'Search' },
{ key: 'library', icon: '♫', label: 'Library' },
{ key: 'profile', icon: '◍', label: 'You' },
] as const;

// Filler rows so there is real content scrolling underneath the bar — the
// preset's refraction is only legible with something moving behind it.
const ROWS = Array.from({ length: 24 }, (_, i) => ({
title: `Track ${i + 1}`,
subtitle: 'Artist — Album',
}));

export default function GlassTabBar({ onBack }: { onBack: () => void }) {
const insets = useSafeAreaInsets();
const [active, setActive] = useState<(typeof TABS)[number]['key']>('home');

return (
<View style={styles.fill}>
<LinearGradient
colors={GRADIENT}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>

<ScrollView
contentContainerStyle={[
styles.content,
// Leave room so the last row can scroll clear of the floating bar.
{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 120 },
]}
>
<View style={styles.headerRow}>
<Pressable onPress={onBack} hitSlop={12}>
<LiquidGlassView variant="clear" borderRadius={16} style={styles.backChip}>
<Text style={styles.backChipText}>‹ Back</Text>
</LiquidGlassView>
</Pressable>
<Text style={styles.heading}>Glass tab bar</Text>
</View>

{ROWS.map((row) => (
<View key={row.title} style={styles.row}>
<View style={styles.rowArt} />
<View>
<Text style={styles.rowTitle}>{row.title}</Text>
<Text style={styles.rowSubtitle}>{row.subtitle}</Text>
</View>
</View>
))}
</ScrollView>

{/* The floating bar itself: one LiquidGlassView, the documented preset,
detached from the screen edge so the rim reads all the way around. */}
<LiquidGlassView
preset="floatingTabBar"
style={[styles.tabBar, { bottom: Math.max(insets.bottom, 16) + 8 }]}
>
<View style={styles.tabRow}>
{TABS.map((tab) => {
const isActive = tab.key === active;
return (
<Pressable
key={tab.key}
onPress={() => setActive(tab.key)}
style={styles.tab}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
accessibilityLabel={tab.label}
>
<Text style={[styles.tabIcon, isActive && styles.tabIconActive]}>{tab.icon}</Text>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
</LiquidGlassView>
</View>
);
}

const styles = StyleSheet.create({
fill: { flex: 1 },
content: { paddingHorizontal: 20 },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: 14, marginBottom: 18 },
backChip: { paddingHorizontal: 14, paddingVertical: 8 },
backChipText: { color: '#fff', fontSize: 15, fontWeight: '600' },
heading: { color: '#fff', fontSize: 22, fontWeight: '700' },

row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 10,
},
rowArt: {
width: 44,
height: 44,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.25)',
},
rowTitle: { color: '#fff', fontSize: 15, fontWeight: '600' },
rowSubtitle: { color: 'rgba(255,255,255,0.7)', fontSize: 12, marginTop: 2 },

tabBar: {
position: 'absolute',
left: 24,
right: 24,
height: 64,
},
tabRow: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
},
tab: { alignItems: 'center', minWidth: 56, paddingVertical: 6 },
tabIcon: { fontSize: 20, color: 'rgba(255,255,255,0.75)' },
tabIconActive: { color: '#fff' },
tabLabel: { fontSize: 11, color: 'rgba(255,255,255,0.65)', marginTop: 2 },
tabLabelActive: { color: '#fff', fontWeight: '600' },
});
24 changes: 24 additions & 0 deletions src/__tests__/presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
'cardOverMedia',
'compactControl',
'frosted',
'toast',
'crystal',
]);
});
Expand Down Expand Up @@ -105,3 +106,26 @@
}
});
});

describe('toast preset', () => {
it('is the most legibility-protective preset — it renders over unknown content', () => {
const toast = GlassPresets.toast;

Check warning on line 112 in src/__tests__/presets.test.ts

View workflow job for this annotation

GitHub Actions / lint

Use object destructuring
// Highest legibilityFloor of any preset: a toast must survive a white page.
for (const name of GLASS_PRESET_NAMES) {
if (name === 'toast') continue;
expect(toast.legibilityFloor ?? 0).toBeGreaterThanOrEqual(
GlassPresets[name].legibilityFloor ?? 0
);
}
// Shallow lens: deep refraction on a 60dp strip warps its own text.
expect(toast.thickness).toBeLessThanOrEqual(0.7);
// Heavy-ish blur to quiet whatever is behind.
expect(toast.intensity).toBeGreaterThanOrEqual(70);
});

it('resolves under user props like every other preset', () => {
const resolved = resolvePreset({ preset: 'toast', borderRadius: 24 });
expect(resolved.borderRadius).toBe(24); // explicit prop wins
expect(resolved.legibilityFloor).toBe(GlassPresets.toast.legibilityFloor);
});
});
49 changes: 49 additions & 0 deletions src/__tests__/shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,55 @@ describe('normalizeShape', () => {
).toBeNull();
});

it('hexagon is a six-sided polygon, point-up by default', () => {
const hex = nn(normalizeShape({ type: 'hexagon' }));
expect(hex.viewBoxWidth).toBe(100);
// Six vertices: one M plus five Ls.
expect(hex.path.match(/L/g) ?? []).toHaveLength(5);
expect(hex.path.trim().endsWith('Z')).toBe(true);
// Identical to the explicit polygon form.
const poly = nn(normalizeShape({ type: 'polygon', sides: 6 }));
expect(hex.path).toBe(poly.path);
});

it('hexagon honours rotation', () => {
const flat = nn(normalizeShape({ type: 'hexagon', rotation: 30 }));
const up = nn(normalizeShape({ type: 'hexagon' }));
expect(flat.path).not.toBe(up.path);
});

it('polygon cornerRadius rounds vertices with quadratic curves', () => {
// cornerRadius was documented on the type but silently ignored — it must
// now emit one Q per vertex and no sharp vertex points.
const rounded = nn(normalizeShape({ type: 'polygon', sides: 6, cornerRadius: 0.3 }));
expect(rounded.path.match(/Q/g) ?? []).toHaveLength(6);
const sharp = nn(normalizeShape({ type: 'polygon', sides: 6 }));
expect(rounded.path).not.toBe(sharp.path);
expect(sharp.path.includes('Q')).toBe(false);
});

it('cornerRadius 0 and out-of-range values stay safe', () => {
const zero = nn(normalizeShape({ type: 'polygon', sides: 5, cornerRadius: 0 }));
expect(zero.path.includes('Q')).toBe(false);
// > 1 clamps rather than folding the shape inside out.
const over = nn(normalizeShape({ type: 'polygon', sides: 5, cornerRadius: 5 }));
expect(over.path.match(/Q/g) ?? []).toHaveLength(5);
expect(over.path).not.toContain('NaN');
});

it('heart is a closed all-cubic silhouette in the 100×100 box', () => {
const heart = nn(normalizeShape({ type: 'heart' }));
expect(heart.viewBoxWidth).toBe(100);
expect(heart.viewBoxHeight).toBe(100);
expect(heart.path.startsWith('M')).toBe(true);
expect(heart.path.trim().endsWith('Z')).toBe(true);
// Two lobes of three cubics each.
expect(heart.path.match(/C/g) ?? []).toHaveLength(6);
// Symmetric: cleft and bottom point sit on the vertical centre line.
expect(heart.path).toContain('M 50 26');
expect(heart.path).toContain('50 92');
});

it('passes an arbitrary SVG path straight through with its view-box', () => {
const d = 'M 0 0 L 100 0 C 100 40 60 40 50 20 Z';
const s = nn(normalizeShape({ type: 'path', d, width: 100, height: 60 }));
Expand Down
20 changes: 20 additions & 0 deletions src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ export const GlassPresets = Object.freeze({
grain: 0.05,
}),

/**
* A notification toast — a small, short-lived surface that must stay legible
* over content it cannot predict (white pages, photos, video).
*
* The tuning is legibility-first: a high `legibilityFloor` (the highest of
* any preset) buys text contrast over a pure-white backdrop, heavy-ish blur
* flattens whatever is behind into a quiet ground, and a shallow lens keeps
* the two lines of text crisp — a deep lens on a 60dp-tall strip just warps
* its own copy. Tuned against a white screen, a busy photo, and scrolling
* text underneath.
*/
toast: Object.freeze<GlassPresetProps>({
variant: 'regular',
intensity: 78,
thickness: 0.5,
edgeReflectionStrength: 0.5,
legibilityFloor: 0.4,
borderRadius: 18,
}),

/**
* Thin, hard and deeply refracting. Decorative — a hero element rather than
* something to put a paragraph of text on.
Expand Down
Loading
Loading