From a0a5c5ff970dae55482e8464150bfebdb200006f Mon Sep 17 00:00:00 2001 From: himanshu-lal4 Date: Fri, 21 Aug 2026 03:48:08 +0530 Subject: [PATCH 1/4] feat: hexagon + heart shapes, toast preset, polygon cornerRadius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shape: { type: 'hexagon', rotation?, cornerRadius? } — first-class sugar for the six-sided polygon (closes #89, half one) - shape: { type: 'heart' } — two cubic lobes meeting at a cleft, tuned to read at chip size (closes #89, half two) - fix: polygon cornerRadius was documented on the type but silently ignored; vertices now round with one quadratic bézier each, capped at half the edge length so roundings can never overlap - preset: 'toast' — legibility-first tuning for a surface that renders over unknown content: the highest legibilityFloor of any preset (0.4), heavy-ish blur (78) to quiet the backdrop, shallow lens (0.5) so the toast's own text stays crisp; tuned against white, a busy photo, and scrolling text (closes #90) Shapes and presets are pure TS — covered by 11 new cases in shapes.test.ts / presets.test.ts (148 total green). --- src/__tests__/presets.test.ts | 24 ++++++++++ src/__tests__/shapes.test.ts | 49 +++++++++++++++++++ src/presets.ts | 20 ++++++++ src/shapes.ts | 88 +++++++++++++++++++++++++++++++++-- 4 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/__tests__/presets.test.ts b/src/__tests__/presets.test.ts index f51fb0a..6bcea3f 100644 --- a/src/__tests__/presets.test.ts +++ b/src/__tests__/presets.test.ts @@ -8,6 +8,7 @@ describe('GlassPresets', () => { 'cardOverMedia', 'compactControl', 'frosted', + 'toast', 'crystal', ]); }); @@ -105,3 +106,26 @@ describe('artistic preset values', () => { } }); }); + +describe('toast preset', () => { + it('is the most legibility-protective preset — it renders over unknown content', () => { + const toast = GlassPresets.toast; + // 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); + }); +}); diff --git a/src/__tests__/shapes.test.ts b/src/__tests__/shapes.test.ts index f1bed1f..c4c5289 100644 --- a/src/__tests__/shapes.test.ts +++ b/src/__tests__/shapes.test.ts @@ -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 })); diff --git a/src/presets.ts b/src/presets.ts index a899957..93c1a28 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -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({ + 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. diff --git a/src/shapes.ts b/src/shapes.ts index 709610f..58e38e3 100644 --- a/src/shapes.ts +++ b/src/shapes.ts @@ -63,6 +63,24 @@ export type LiquidGlassShape = type: 'points'; points: Array; } + | { + /** + * A regular hexagon — sugar for `{ type: 'polygon', sides: 6 }`, kept as + * a first-class name because it is asked for constantly. + */ + type: 'hexagon'; + /** Rotation in degrees. 0 is flat-top… point-up. @default 0 */ + rotation?: number; + /** Optional corner rounding, 0–1 as a fraction of the radius. @default 0 */ + cornerRadius?: number; + } + | { + /** + * A heart inscribed in the bounds — two cubic lobes meeting at a cleft, + * tapering to the bottom point. + */ + type: 'heart'; + } | { /** * A raw SVG path. Supports M/L/H/V/C/S/Q/T/Z (absolute + relative). Elliptic @@ -134,8 +152,15 @@ function squircleShape(n: number): NormalizedShape { return { path: polylinePath(pts), ...UNIT_BOX }; } -/** Regular N-gon inscribed in the 100×100 box, first vertex up (before rotation). */ -function polygonShape(sides: number, rotationDeg: number): NormalizedShape { +/** + * Regular N-gon inscribed in the 100×100 box, first vertex up (before rotation). + * + * `cornerRadius` (0–1, fraction of the radius) rounds each vertex with a + * quadratic bézier: each edge is shortened by the rounding distance at both + * ends and the vertex becomes the control point. Previously this option was + * documented on the type but silently ignored. + */ +function polygonShape(sides: number, rotationDeg: number, cornerRadius = 0): NormalizedShape { const count = Math.max(3, Math.round(sides)); const rot = (rotationDeg * Math.PI) / 180; const verts: Array = []; @@ -143,7 +168,58 @@ function polygonShape(sides: number, rotationDeg: number): NormalizedShape { const a = -Math.PI / 2 + rot + (i / count) * Math.PI * 2; verts.push([50 + Math.cos(a) * 50, 50 + Math.sin(a) * 50]); } - return { path: polylinePath(verts), ...UNIT_BOX }; + + const round = Math.min(1, Math.max(0, cornerRadius)); + if (round <= 0) return { path: polylinePath(verts), ...UNIT_BOX }; + + // Rounding distance along each edge, capped at half the edge length so + // adjacent roundings can never overlap. + const edge = Math.hypot(verts[1]![0] - verts[0]![0], verts[1]![1] - verts[0]![1]); + const d = Math.min(round * 50, edge / 2); + + const lerpToward = ( + from: readonly [number, number], + to: readonly [number, number] + ): readonly [number, number] => { + const len = Math.hypot(to[0] - from[0], to[1] - from[1]); + const t = len === 0 ? 0 : d / len; + return [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]; + }; + + const cmds: string[] = []; + for (let i = 0; i < count; i++) { + const prev = verts[(i - 1 + count) % count]!; + const v = verts[i]!; + const next = verts[(i + 1) % count]!; + const inPt = lerpToward(v, prev); // rounding start, on the incoming edge + const outPt = lerpToward(v, next); // rounding end, on the outgoing edge + cmds.push(i === 0 ? `M ${fmt(inPt[0])} ${fmt(inPt[1])}` : `L ${fmt(inPt[0])} ${fmt(inPt[1])}`); + cmds.push(`Q ${fmt(v[0])} ${fmt(v[1])} ${fmt(outPt[0])} ${fmt(outPt[1])}`); + } + cmds.push('Z'); + return { path: cmds.join(' '), ...UNIT_BOX }; +} + +/** + * Heart in the 100×100 box: two cubic lobes from the cleft (top centre) around + * to the bottom point. Proportions tuned so the silhouette reads as a heart at + * chip size, not just full-screen. + */ +function heartShape(): NormalizedShape { + const path = [ + // cleft + 'M 50 26', + // left lobe: up-and-out, around, then down to the bottom point + 'C 42 12 24 8 13.5 18', + 'C 3 28 3.5 45 13 56.5', + 'C 22 67.5 38 80 50 92', + // right lobe, mirrored + 'C 62 80 78 67.5 87 56.5', + 'C 96.5 45 97 28 86.5 18', + 'C 76 8 58 12 50 26', + 'Z', + ].join(' '); + return { path, ...UNIT_BOX }; } /** @@ -198,7 +274,11 @@ export function normalizeShape(shape: LiquidGlassShape): NormalizedShape | null case 'squircle': return squircleShape(shape.n ?? 4); case 'polygon': - return polygonShape(shape.sides, shape.rotation ?? 0); + return polygonShape(shape.sides, shape.rotation ?? 0, shape.cornerRadius ?? 0); + case 'hexagon': + return polygonShape(6, shape.rotation ?? 0, shape.cornerRadius ?? 0); + case 'heart': + return heartShape(); case 'star': return starShape(shape.points ?? 5, shape.innerRatio ?? 0.5); case 'points': From 1383b294aacde2fa801e74ebbad924731071b142 Mon Sep 17 00:00:00 2001 From: himanshu-lal4 Date: Fri, 21 Aug 2026 03:48:08 +0530 Subject: [PATCH 2/4] feat(example): glass tab bar screen (closes #88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating tab bar built from , detached from the screen edge so the rim reads all the way around, with real content scrolling underneath. Registered as the '▬ Tab bar' chip in the API gallery, mirroring docs/react-native-glass-tab-bar.md so the documented recipe is exercised on-device. --- example/src/App.tsx | 21 +++++- example/src/GlassTabBar.tsx | 145 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 example/src/GlassTabBar.tsx diff --git a/example/src/App.tsx b/example/src/App.tsx index bdf1db7..4b5721b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -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'; @@ -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 ( @@ -122,6 +123,8 @@ export default function App() { setMode('gallery')} /> ) : mode === 'cards' ? ( setMode('gallery')} /> + ) : mode === 'tabbar' ? ( + setMode('gallery')} /> ) : mode === 'mercury' ? ( setMode('gallery')} /> ) : mode === 'stress' ? ( @@ -130,6 +133,7 @@ export default function App() { setMode('reel')} onShowCards={() => setMode('cards')} + onShowTabBar={() => setMode('tabbar')} onShowMercury={() => setMode('mercury')} onShowStress={() => setMode('stress')} /> @@ -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; }) { @@ -177,6 +183,15 @@ function Gallery({ ▦ Cards + + + ▬ Tab bar + + `. 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 ( + + + + + + + + ‹ Back + + + Glass tab bar + + + {ROWS.map((row) => ( + + + + {row.title} + {row.subtitle} + + + ))} + + + {/* The floating bar itself: one LiquidGlassView, the documented preset, + detached from the screen edge so the rim reads all the way around. */} + + + {TABS.map((tab) => { + const isActive = tab.key === active; + return ( + setActive(tab.key)} + style={styles.tab} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={tab.label} + > + {tab.icon} + + {tab.label} + + + ); + })} + + + + ); +} + +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' }, +}); From f443900e50392be63b2f538de88daa7b5b2c6b5c Mon Sep 17 00:00:00 2001 From: himanshu-lal4 Date: Fri, 21 Aug 2026 03:48:08 +0530 Subject: [PATCH 3/4] docs: 'React Native Liquid Glass' H1, honest comparison FAQ, sponsor buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1 now carries the exact phrase people actually search — 'React Native Liquid Glass' — with the package name demoted to a subtitle; the phrase never appeared as a token pair in the page's strongest element because 'liquid-glassmorphism' tokenizes without the standalone word 'glass' - FAQ: how this differs from react-native-liquid-glass (Expo) and @callstack/liquid-glass — they wrap UIGlassEffect (iOS-26-only); this drives the same native effect on iOS AND reproduces the optics on Android 13+, plus glass merging (claim-discipline per growth notes: no 'only Android glass' claims) - GitHub Sponsors + Buy Me a Coffee buttons (theme-aware) and FUNDING.yml --- .github/FUNDING.yml | 3 +++ README.md | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6cdbdbd --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# Shown in GitHub's "Sponsor this project" sidebar +github: himanshu-lal4 +buy_me_a_coffee: wrack diff --git a/README.md b/README.md index 88fea85..551fe8e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# react-native-liquid-glassmorphism +# React Native Liquid Glass — for Android AND iOS + +

react-native-liquid-glassmorphism

npm version @@ -16,6 +18,14 @@ 📦 Install from npm

+

+ Sponsor on GitHub +   + Buy Me a Coffee +

+ +

If this library saves you a native module, a ⭐ and a coffee keep it maintained.

+

🤖 Works on Android too — not an iOS-only wrapper. 🍏
Most React Native Liquid Glass libraries are iOS-only. This one renders real glass on both platforms from the same component. @@ -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 ``. 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). From 6e0e95cec5cda5cfffa41ef923f60783b9cd936f Mon Sep 17 00:00:00 2001 From: himanshu-lal4 Date: Fri, 21 Aug 2026 04:01:51 +0530 Subject: [PATCH 4/4] fix: drop non-null assertions from polygon corner rounding (CI lint) Wrap-around vertex access goes through a bounds-safe accessor with an unreachable fallback instead of seven `!` assertions. --- src/shapes.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/shapes.ts b/src/shapes.ts index 58e38e3..1e9a1b6 100644 --- a/src/shapes.ts +++ b/src/shapes.ts @@ -172,9 +172,14 @@ function polygonShape(sides: number, rotationDeg: number, cornerRadius = 0): Nor const round = Math.min(1, Math.max(0, cornerRadius)); if (round <= 0) return { path: polylinePath(verts), ...UNIT_BOX }; + // Wrap-around vertex access without non-null assertions (count >= 3, so the + // fallback is unreachable — it exists to satisfy noUncheckedIndexedAccess). + const at = (i: number): readonly [number, number] => + verts[((i % count) + count) % count] ?? [50, 50]; + // Rounding distance along each edge, capped at half the edge length so // adjacent roundings can never overlap. - const edge = Math.hypot(verts[1]![0] - verts[0]![0], verts[1]![1] - verts[0]![1]); + const edge = Math.hypot(at(1)[0] - at(0)[0], at(1)[1] - at(0)[1]); const d = Math.min(round * 50, edge / 2); const lerpToward = ( @@ -188,9 +193,9 @@ function polygonShape(sides: number, rotationDeg: number, cornerRadius = 0): Nor const cmds: string[] = []; for (let i = 0; i < count; i++) { - const prev = verts[(i - 1 + count) % count]!; - const v = verts[i]!; - const next = verts[(i + 1) % count]!; + const prev = at(i - 1); + const v = at(i); + const next = at(i + 1); const inPt = lerpToward(v, prev); // rounding start, on the incoming edge const outPt = lerpToward(v, next); // rounding end, on the outgoing edge cmds.push(i === 0 ? `M ${fmt(inPt[0])} ${fmt(inPt[1])}` : `L ${fmt(inPt[0])} ${fmt(inPt[1])}`);