@aberhamm/liquid-glass-react 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +38 -0
- package/README.md +627 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +1191 -0
- package/dist/index.d.ts +1191 -0
- package/dist/index.mjs +1314 -0
- package/dist/liquid-glass-react.css +1 -0
- package/dist/native/index.native.d.ts +19 -0
- package/dist/native/index.native.js +19 -0
- package/dist/native/liquid-glass.native.d.ts +65 -0
- package/dist/native/liquid-glass.native.js +200 -0
- package/dist/native/tokens.d.ts +245 -0
- package/dist/native/tokens.js +254 -0
- package/dist/native/types.d.ts +464 -0
- package/dist/native/types.js +9 -0
- package/package.json +122 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.lg-content{display:inline-flex;align-items:center;justify-content:center;gap:.5em;color:var(--lg-fg, rgba(17, 17, 19, .92));text-shadow:0 1px 1px rgba(255,255,255,.45);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}@media(prefers-color-scheme:dark){.lg-content{color:var(--lg-fg, rgba(245, 245, 247, .94));text-shadow:0 1px 2px rgba(0,0,0,.55)}}.lg-button{position:relative;-moz-appearance:none;appearance:none;-webkit-appearance:none;border:0;margin:0;background:transparent;font:inherit;font-weight:600;line-height:1.2;letter-spacing:.01em;cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;color:var(--lg-fg, rgba(17, 17, 19, .92));text-decoration:none;border-radius:inherit;transition:filter .16s ease,opacity .16s ease}.lg-button:hover:not(:disabled){filter:brightness(1.04)}.lg-button:focus-visible{outline:2px solid var(--lg-focus-ring, rgba(60, 130, 255, .9));outline-offset:2px;box-shadow:0 0 0 4px #3c82ff40}.lg-button:disabled{cursor:not-allowed;opacity:.5;filter:saturate(.7)}@media(prefers-contrast:more){.lg-content{color:var(--lg-fg, rgba(0, 0, 0, 1));text-shadow:none}.lg-button:focus-visible,.lg-segmented-option:has(.lg-segmented-input:focus-visible){outline:3px solid var(--lg-focus-ring, rgba(0, 90, 255, 1));outline-offset:2px;box-shadow:0 0 0 5px #ffffffe6}}@media(prefers-contrast:more)and (prefers-color-scheme:dark){.lg-content{color:var(--lg-fg, rgba(255, 255, 255, 1));text-shadow:none}.lg-button:focus-visible,.lg-segmented-option:has(.lg-segmented-input:focus-visible){box-shadow:0 0 0 5px #000000e6}}.lg-button[data-shine=true]:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:inherit;pointer-events:none;background:linear-gradient(105deg,#fff0 30%,#ffffff8c,#fff0 70%);background-size:250% 100%;background-position:150% 0;opacity:0}.lg-button[data-shine=true]:active:not(:disabled):after{animation:lg-shine .48s ease-out}@keyframes lg-shine{0%{background-position:150% 0;opacity:0}20%{opacity:1}to{background-position:-50% 0;opacity:0}}@media(prefers-reduced-motion:reduce){.lg-button[data-shine=true]:active:not(:disabled):after{animation:none}}.lg-button--primary{--lg-fg: rgba(255, 255, 255, .98);text-shadow:0 1px 2px rgba(0,0,0,.35)}.lg-button--secondary{--lg-fg: rgba(20, 22, 28, .92)}.lg-button--subtle{--lg-fg: rgba(40, 42, 50, .82);font-weight:500}@media(prefers-color-scheme:dark){.lg-button--secondary{--lg-fg: rgba(240, 242, 248, .94)}.lg-button--subtle{--lg-fg: rgba(225, 227, 235, .85)}}.lg-button--sm{font-size:.8125rem}.lg-button--md{font-size:.9375rem}.lg-button--lg{font-size:1.0625rem}.lg-button--icon{font-size:1rem}.lg-button--icon .lg-content{width:1.25em;height:1.25em;gap:0}.lg-card{position:relative;display:block;border-radius:inherit}.lg-card .lg-content{display:block;text-align:left;text-shadow:none;color:var(--lg-fg, rgba(20, 22, 28, .9))}@media(prefers-color-scheme:dark){.lg-card .lg-content{color:var(--lg-fg, rgba(238, 240, 246, .92))}}.lg-card--flat{--lg-card-lift: none}.lg-card--raised{--lg-card-lift: 0 10px 30px rgba(0, 0, 0, .16)}.lg-card--floating{--lg-card-lift: 0 22px 60px rgba(0, 0, 0, .24)}.lg-card[data-elevation]:not(.lg-card--flat){filter:drop-shadow(var(--lg-card-lift, none))}.lg-segmented{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:0;margin:0;padding:0;min-inline-size:0;display:inline-flex;flex-direction:column;gap:.5rem;--lg-seg-pad: .25rem;--lg-seg-radius: 999px;font:inherit}.lg-segmented-legend{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.lg-segmented-legend--visible{position:static;width:auto;height:auto;margin:0;clip:auto;overflow:visible;white-space:normal;font-size:.8125rem;font-weight:600;letter-spacing:.02em;color:var(--lg-fg, rgba(20, 22, 28, .82))}@media(prefers-color-scheme:dark){.lg-segmented-legend--visible{color:var(--lg-fg, rgba(232, 234, 240, .86))}}.lg-segmented-track{position:relative;display:inline-flex;align-items:stretch;padding:var(--lg-seg-pad);border-radius:var(--lg-seg-radius);background:#ffffff1f;box-shadow:var(--lg-edge-shadow);isolation:isolate;-webkit-tap-highlight-color:transparent}@media(prefers-color-scheme:dark){.lg-segmented-track{background:#14161c38}}.lg-segmented-indicator{position:absolute;top:var(--lg-seg-pad);left:var(--lg-seg-pad);bottom:var(--lg-seg-pad);border-radius:var(--lg-seg-radius);z-index:0;pointer-events:none;will-change:transform,width}.lg-segmented-glass{border-radius:var(--lg-seg-radius)}.lg-segmented-glass-fill{display:block;width:100%;height:100%}.lg-segmented-option{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;gap:.4em;flex:1 1 auto;border-radius:var(--lg-seg-radius);cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap;font-weight:600;letter-spacing:.01em;color:var(--lg-fg, rgba(40, 42, 50, .7));transition:color .2s ease}.lg-segmented-option[data-active=true]{color:var(--lg-fg-active, rgba(17, 17, 19, .96))}@media(prefers-color-scheme:dark){.lg-segmented-option{color:var(--lg-fg, rgba(220, 222, 230, .72))}.lg-segmented-option[data-active=true]{color:var(--lg-fg-active, rgba(248, 249, 252, .98))}}.lg-segmented-input{position:absolute;width:1px;height:1px;padding:0;margin:0;opacity:0;pointer-events:none;clip:rect(0,0,0,0)}.lg-segmented-input:focus-visible+.lg-segmented-icon,.lg-segmented-input:focus-visible~.lg-segmented-label,.lg-segmented-option:focus-within{outline:none}.lg-segmented-option:has(.lg-segmented-input:focus-visible){outline:2px solid var(--lg-focus-ring, rgba(60, 130, 255, .9));outline-offset:2px;box-shadow:0 0 0 4px #3c82ff40}.lg-segmented-option:has(.lg-segmented-input:disabled){cursor:not-allowed;opacity:.45}.lg-segmented-icon{display:inline-flex;align-items:center;justify-content:center;line-height:0}.lg-segmented-option[data-icon-only=true]{aspect-ratio:1 / 1}.lg-segmented--sm{font-size:.8125rem}.lg-segmented--sm .lg-segmented-option{padding:.3rem .7rem}.lg-segmented--md{font-size:.9375rem}.lg-segmented--md .lg-segmented-option{padding:.45rem 1rem}.lg-segmented--lg{font-size:1.0625rem}.lg-segmented--lg .lg-segmented-option{padding:.6rem 1.35rem}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native entry point for @aberhamm/liquid-glass-react (plan 025).
|
|
3
|
+
*
|
|
4
|
+
* Metro resolves this via the `react-native` export condition (see
|
|
5
|
+
* `package.json`). It barrels ONLY the platform-neutral surface: the native
|
|
6
|
+
* `<LiquidGlass>` primitive, the shared design tokens (plan 022), and the
|
|
7
|
+
* platform-neutral public types.
|
|
8
|
+
*
|
|
9
|
+
* It deliberately does NOT re-export the DOM-only prebuilt components
|
|
10
|
+
* (`GlassButton` / `GlassCard` / `GlassSegmentedControl`) or the DOM-only hooks
|
|
11
|
+
* (capability detection, backdrop-luminance sampling, mouse tracking), so Metro
|
|
12
|
+
* never pulls web-only code (`document`, `window`, `backdrop-filter`, SVG) into a
|
|
13
|
+
* native bundle. Native ports of the prebuilt components are out of scope for
|
|
14
|
+
* this round (primitive only).
|
|
15
|
+
*/
|
|
16
|
+
export declare const VERSION = "0.0.0";
|
|
17
|
+
export { LiquidGlass, type LiquidGlassNativeProps } from './liquid-glass.native';
|
|
18
|
+
export type { DisplacementMode, GlassVariant, LiquidGlassCoreProps, MousePos, } from './types';
|
|
19
|
+
export * from './tokens';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Native entry point for @aberhamm/liquid-glass-react (plan 025).
|
|
3
|
+
*
|
|
4
|
+
* Metro resolves this via the `react-native` export condition (see
|
|
5
|
+
* `package.json`). It barrels ONLY the platform-neutral surface: the native
|
|
6
|
+
* `<LiquidGlass>` primitive, the shared design tokens (plan 022), and the
|
|
7
|
+
* platform-neutral public types.
|
|
8
|
+
*
|
|
9
|
+
* It deliberately does NOT re-export the DOM-only prebuilt components
|
|
10
|
+
* (`GlassButton` / `GlassCard` / `GlassSegmentedControl`) or the DOM-only hooks
|
|
11
|
+
* (capability detection, backdrop-luminance sampling, mouse tracking), so Metro
|
|
12
|
+
* never pulls web-only code (`document`, `window`, `backdrop-filter`, SVG) into a
|
|
13
|
+
* native bundle. Native ports of the prebuilt components are out of scope for
|
|
14
|
+
* this round (primitive only).
|
|
15
|
+
*/
|
|
16
|
+
export const VERSION = '0.0.0';
|
|
17
|
+
export { LiquidGlass } from './liquid-glass.native';
|
|
18
|
+
// Shared design tokens (plan 022) — the same source of truth the web build uses.
|
|
19
|
+
export * from './tokens';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<LiquidGlass>` — React Native surface (plan 025).
|
|
3
|
+
*
|
|
4
|
+
* The web `<LiquidGlass>` (`liquid-glass.tsx`) renders a tiered
|
|
5
|
+
* `backdrop-filter` recipe with an OPT-IN, Chromium-only SVG refraction pass.
|
|
6
|
+
* React Native has no DOM, no `backdrop-filter`, and no SVG-over-backdrop
|
|
7
|
+
* compositing, so this surface renders ONLY the portable frosted recipe — the
|
|
8
|
+
* same look the web file falls back to on every non-Chromium engine — built from
|
|
9
|
+
* real native primitives:
|
|
10
|
+
*
|
|
11
|
+
* 1. an `expo-blur` `BlurView` for the frosted backdrop (a real iOS
|
|
12
|
+
* `UIVisualEffectView`; on Android `experimentalBlurMethod` approximates it
|
|
13
|
+
* and degrades gracefully);
|
|
14
|
+
* 2. a white TINT overlay (`FROSTED_DEFAULT_FILL`) that gives the surface its
|
|
15
|
+
* milky body — alpha is raised slightly on Android where the blur is weaker
|
|
16
|
+
* so the surface never reads as a bare transparent box;
|
|
17
|
+
* 3. an `expo-linear-gradient` SHEEN highlight (top-biased specular, from the
|
|
18
|
+
* `SPECULAR_*` tokens);
|
|
19
|
+
* 4. a ~1px hairline ring (`FROSTED_HAIRLINE_RING`);
|
|
20
|
+
* 5. a soft drop shadow (iOS `shadow*` / Android `elevation`) from
|
|
21
|
+
* `SHADOW_LAYERS`.
|
|
22
|
+
*
|
|
23
|
+
* Every geometry/color value comes from `src/tokens.ts`, the one source of truth
|
|
24
|
+
* shared with the web file — this module only SERIALIZES those values into RN
|
|
25
|
+
* style objects (the RN analogue of the web file owning the CSS strings).
|
|
26
|
+
*
|
|
27
|
+
* ## Prop contract
|
|
28
|
+
*
|
|
29
|
+
* Typed on the platform-neutral {@link LiquidGlassCoreProps} (plan 022) plus
|
|
30
|
+
* native-appropriate `style?: ViewStyle` and `onPress?` — it NEVER references the
|
|
31
|
+
* web `LiquidGlassProps`'s DOM-only style/event types. The event handler is
|
|
32
|
+
* platform-idiomatic: native exposes `onPress` (web keeps `onClick`).
|
|
33
|
+
*
|
|
34
|
+
* The web-only refraction/motion props are accepted for a single cross-platform
|
|
35
|
+
* API contract but are DOCUMENTED NO-OPs here — native always renders frosted:
|
|
36
|
+
* `displacement`, `displacementScale`, `aberrationIntensity`, `elasticity`,
|
|
37
|
+
* `saturation` (no native backdrop-saturation control), `overLight`,
|
|
38
|
+
* `adaptiveTint`, `scrollAwareShadow`, `variant`, `mode`, `className`, and the
|
|
39
|
+
* plan-030 cross-browser copy-refraction props `refract`, `size`, `center`,
|
|
40
|
+
* `behind` (RN has no SVG element filters, so there is no copy-refraction path).
|
|
41
|
+
*/
|
|
42
|
+
import type { ReactNode } from 'react';
|
|
43
|
+
import { type ViewStyle } from 'react-native';
|
|
44
|
+
import type { LiquidGlassCoreProps } from './types';
|
|
45
|
+
/**
|
|
46
|
+
* Props for the React Native `<LiquidGlass>` surface: the platform-neutral core
|
|
47
|
+
* (plan 022) plus native `style` and an `onPress` handler. Distinct from the web
|
|
48
|
+
* `LiquidGlassProps`, which carries DOM-only style/event types.
|
|
49
|
+
*/
|
|
50
|
+
export type LiquidGlassNativeProps = LiquidGlassCoreProps & {
|
|
51
|
+
/** Style merged onto the outermost native `View`. */
|
|
52
|
+
style?: ViewStyle;
|
|
53
|
+
/** Press handler (native-idiomatic; the web surface uses `onClick`). */
|
|
54
|
+
onPress?: () => void;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The React Native liquid-glass primitive. See the file header for the layer
|
|
58
|
+
* stack, the token mapping, and the web-only prop no-ops.
|
|
59
|
+
*
|
|
60
|
+
* The web-only props (`displacement`, `saturation`, `variant`, `mode`, `refract`,
|
|
61
|
+
* `size`, `center`, `behind`, etc.) are intentionally NOT read — they're accepted
|
|
62
|
+
* so the cross-platform API is uniform, but native always renders the frosted
|
|
63
|
+
* recipe.
|
|
64
|
+
*/
|
|
65
|
+
export declare function LiquidGlass({ children, blurAmount, cornerRadius, padding, style, onPress, }: LiquidGlassNativeProps): ReactNode;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<LiquidGlass>` — React Native surface (plan 025).
|
|
4
|
+
*
|
|
5
|
+
* The web `<LiquidGlass>` (`liquid-glass.tsx`) renders a tiered
|
|
6
|
+
* `backdrop-filter` recipe with an OPT-IN, Chromium-only SVG refraction pass.
|
|
7
|
+
* React Native has no DOM, no `backdrop-filter`, and no SVG-over-backdrop
|
|
8
|
+
* compositing, so this surface renders ONLY the portable frosted recipe — the
|
|
9
|
+
* same look the web file falls back to on every non-Chromium engine — built from
|
|
10
|
+
* real native primitives:
|
|
11
|
+
*
|
|
12
|
+
* 1. an `expo-blur` `BlurView` for the frosted backdrop (a real iOS
|
|
13
|
+
* `UIVisualEffectView`; on Android `experimentalBlurMethod` approximates it
|
|
14
|
+
* and degrades gracefully);
|
|
15
|
+
* 2. a white TINT overlay (`FROSTED_DEFAULT_FILL`) that gives the surface its
|
|
16
|
+
* milky body — alpha is raised slightly on Android where the blur is weaker
|
|
17
|
+
* so the surface never reads as a bare transparent box;
|
|
18
|
+
* 3. an `expo-linear-gradient` SHEEN highlight (top-biased specular, from the
|
|
19
|
+
* `SPECULAR_*` tokens);
|
|
20
|
+
* 4. a ~1px hairline ring (`FROSTED_HAIRLINE_RING`);
|
|
21
|
+
* 5. a soft drop shadow (iOS `shadow*` / Android `elevation`) from
|
|
22
|
+
* `SHADOW_LAYERS`.
|
|
23
|
+
*
|
|
24
|
+
* Every geometry/color value comes from `src/tokens.ts`, the one source of truth
|
|
25
|
+
* shared with the web file — this module only SERIALIZES those values into RN
|
|
26
|
+
* style objects (the RN analogue of the web file owning the CSS strings).
|
|
27
|
+
*
|
|
28
|
+
* ## Prop contract
|
|
29
|
+
*
|
|
30
|
+
* Typed on the platform-neutral {@link LiquidGlassCoreProps} (plan 022) plus
|
|
31
|
+
* native-appropriate `style?: ViewStyle` and `onPress?` — it NEVER references the
|
|
32
|
+
* web `LiquidGlassProps`'s DOM-only style/event types. The event handler is
|
|
33
|
+
* platform-idiomatic: native exposes `onPress` (web keeps `onClick`).
|
|
34
|
+
*
|
|
35
|
+
* The web-only refraction/motion props are accepted for a single cross-platform
|
|
36
|
+
* API contract but are DOCUMENTED NO-OPs here — native always renders frosted:
|
|
37
|
+
* `displacement`, `displacementScale`, `aberrationIntensity`, `elasticity`,
|
|
38
|
+
* `saturation` (no native backdrop-saturation control), `overLight`,
|
|
39
|
+
* `adaptiveTint`, `scrollAwareShadow`, `variant`, `mode`, `className`, and the
|
|
40
|
+
* plan-030 cross-browser copy-refraction props `refract`, `size`, `center`,
|
|
41
|
+
* `behind` (RN has no SVG element filters, so there is no copy-refraction path).
|
|
42
|
+
*/
|
|
43
|
+
import { BlurView } from 'expo-blur';
|
|
44
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
45
|
+
import { Platform, Pressable, StyleSheet, View } from 'react-native';
|
|
46
|
+
import { BLUR_PX_PER_UNIT, DEFAULTS, FROSTED_DEFAULT_FILL, FROSTED_HAIRLINE_RING, SHADOW_LAYERS, SPECULAR_CORE_OPACITY, } from './tokens';
|
|
47
|
+
/**
|
|
48
|
+
* Maps the small public `blurAmount` (a rem-ish factor, default `0.0625`) onto an
|
|
49
|
+
* `expo-blur` `intensity` (0–100). The web file turns `blurAmount` into a px blur
|
|
50
|
+
* via {@link BLUR_PX_PER_UNIT}; native has no px blur radius, so we reuse that
|
|
51
|
+
* same px figure and scale it into the intensity scale. Default `blurAmount`
|
|
52
|
+
* lands at ~`intensity: 25`, a legible frosted default; larger values ramp up and
|
|
53
|
+
* saturate at 100.
|
|
54
|
+
*/
|
|
55
|
+
const NATIVE_BLUR_INTENSITY_SCALE = 25;
|
|
56
|
+
/**
|
|
57
|
+
* Extra tint alpha added on Android, where `expo-blur` (even with
|
|
58
|
+
* `experimentalBlurMethod`) blurs less convincingly than the iOS
|
|
59
|
+
* `UIVisualEffectView`. Raising the fill keeps the surface legible instead of a
|
|
60
|
+
* near-transparent box — the documented Android degradation boundary.
|
|
61
|
+
*/
|
|
62
|
+
const ANDROID_TINT_ALPHA_BOOST = 0.12;
|
|
63
|
+
function clamp(value, min, max) {
|
|
64
|
+
return Math.min(Math.max(value, min), max);
|
|
65
|
+
}
|
|
66
|
+
/** Coerce the public `cornerRadius` (number | CSS length string) to an RN radius. */
|
|
67
|
+
function toRadius(value) {
|
|
68
|
+
if (typeof value === 'number')
|
|
69
|
+
return value;
|
|
70
|
+
const parsed = Number.parseFloat(value);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : Number(DEFAULTS.cornerRadius) || 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Coerce the public `padding` (number | CSS shorthand string like `'24px 32px'`)
|
|
75
|
+
* to an RN padding style. Percentage/keyword tokens fall back to a numeric
|
|
76
|
+
* default — RN paddings must be numbers.
|
|
77
|
+
*/
|
|
78
|
+
function toPadding(value) {
|
|
79
|
+
if (typeof value === 'number')
|
|
80
|
+
return { padding: value };
|
|
81
|
+
const parts = value
|
|
82
|
+
.trim()
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.map((token) => Number.parseFloat(token))
|
|
85
|
+
.filter((n) => Number.isFinite(n));
|
|
86
|
+
if (parts.length === 1)
|
|
87
|
+
return { padding: parts[0] ?? 0 };
|
|
88
|
+
if (parts.length === 2) {
|
|
89
|
+
return { paddingVertical: parts[0] ?? 0, paddingHorizontal: parts[1] ?? 0 };
|
|
90
|
+
}
|
|
91
|
+
if (parts.length === 3) {
|
|
92
|
+
return {
|
|
93
|
+
paddingTop: parts[0] ?? 0,
|
|
94
|
+
paddingHorizontal: parts[1] ?? 0,
|
|
95
|
+
paddingBottom: parts[2] ?? 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (parts.length >= 4) {
|
|
99
|
+
return {
|
|
100
|
+
paddingTop: parts[0] ?? 0,
|
|
101
|
+
paddingRight: parts[1] ?? 0,
|
|
102
|
+
paddingBottom: parts[2] ?? 0,
|
|
103
|
+
paddingLeft: parts[3] ?? 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { padding: 24 };
|
|
107
|
+
}
|
|
108
|
+
/** Parse an `rgb()/rgba()` string into channels, or `null` if it isn't one. */
|
|
109
|
+
function parseRgba(color) {
|
|
110
|
+
const match = color.match(/rgba?\(([^)]+)\)/i);
|
|
111
|
+
if (!match || !match[1])
|
|
112
|
+
return null;
|
|
113
|
+
const parts = match[1].split(',').map((token) => Number.parseFloat(token.trim()));
|
|
114
|
+
if (parts.length < 3)
|
|
115
|
+
return null;
|
|
116
|
+
return { r: parts[0] ?? 0, g: parts[1] ?? 0, b: parts[2] ?? 0, a: parts[3] ?? 1 };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The frosted tint fill. iOS uses the shared {@link FROSTED_DEFAULT_FILL}; Android
|
|
120
|
+
* raises the alpha by {@link ANDROID_TINT_ALPHA_BOOST} for legibility under its
|
|
121
|
+
* weaker blur. Falls back to the raw token if the color can't be parsed.
|
|
122
|
+
*/
|
|
123
|
+
function resolveTintFill() {
|
|
124
|
+
if (Platform.OS !== 'android')
|
|
125
|
+
return FROSTED_DEFAULT_FILL;
|
|
126
|
+
const parsed = parseRgba(FROSTED_DEFAULT_FILL);
|
|
127
|
+
if (!parsed)
|
|
128
|
+
return FROSTED_DEFAULT_FILL;
|
|
129
|
+
const alpha = clamp(parsed.a + ANDROID_TINT_ALPHA_BOOST, 0, 1);
|
|
130
|
+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`;
|
|
131
|
+
}
|
|
132
|
+
const styles = StyleSheet.create({
|
|
133
|
+
// Inline-block-ish container: shrinks to content, carries padding + shadow. It
|
|
134
|
+
// does NOT clip (no overflow:hidden) so the iOS shadow / Android elevation is
|
|
135
|
+
// not cut off; the blur is clipped by the inner `clip` layer instead.
|
|
136
|
+
container: {
|
|
137
|
+
position: 'relative',
|
|
138
|
+
alignSelf: 'flex-start',
|
|
139
|
+
shadowColor: '#000',
|
|
140
|
+
},
|
|
141
|
+
// Absolutely-filled clip layer: rounds + clips the BlurView/tint/sheen to the
|
|
142
|
+
// corner radius and carries the hairline ring. A sibling BEHIND the content.
|
|
143
|
+
clip: {
|
|
144
|
+
...StyleSheet.absoluteFillObject,
|
|
145
|
+
overflow: 'hidden',
|
|
146
|
+
borderWidth: FROSTED_HAIRLINE_RING.width,
|
|
147
|
+
borderColor: FROSTED_HAIRLINE_RING.color,
|
|
148
|
+
},
|
|
149
|
+
fill: StyleSheet.absoluteFillObject,
|
|
150
|
+
// Content sits above the surface layers, unaffected by the blur/tint/sheen.
|
|
151
|
+
content: {
|
|
152
|
+
position: 'relative',
|
|
153
|
+
zIndex: 1,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
/**
|
|
157
|
+
* The React Native liquid-glass primitive. See the file header for the layer
|
|
158
|
+
* stack, the token mapping, and the web-only prop no-ops.
|
|
159
|
+
*
|
|
160
|
+
* The web-only props (`displacement`, `saturation`, `variant`, `mode`, `refract`,
|
|
161
|
+
* `size`, `center`, `behind`, etc.) are intentionally NOT read — they're accepted
|
|
162
|
+
* so the cross-platform API is uniform, but native always renders the frosted
|
|
163
|
+
* recipe.
|
|
164
|
+
*/
|
|
165
|
+
export function LiquidGlass({ children, blurAmount = DEFAULTS.blurAmount, cornerRadius = DEFAULTS.cornerRadius, padding = DEFAULTS.padding, style, onPress, }) {
|
|
166
|
+
const radius = toRadius(cornerRadius);
|
|
167
|
+
// Reuse the web px-blur mapping (blurAmount * BLUR_PX_PER_UNIT) then project it
|
|
168
|
+
// onto expo-blur's 0–100 intensity scale.
|
|
169
|
+
const blurPx = blurAmount * BLUR_PX_PER_UNIT;
|
|
170
|
+
const intensity = clamp(Math.round(blurPx * NATIVE_BLUR_INTENSITY_SCALE), 0, 100);
|
|
171
|
+
const tintFill = resolveTintFill();
|
|
172
|
+
// Top-biased specular sheen mirroring the web highlight's rest stops
|
|
173
|
+
// (SPECULAR_REST at top-center): bright core → mid → transparent.
|
|
174
|
+
const core = SPECULAR_CORE_OPACITY.rest;
|
|
175
|
+
const sheenColors = [
|
|
176
|
+
`rgba(255, 255, 255, ${core})`,
|
|
177
|
+
`rgba(255, 255, 255, ${(core * 0.4).toFixed(3)})`,
|
|
178
|
+
'rgba(255, 255, 255, 0)',
|
|
179
|
+
];
|
|
180
|
+
// Primary drop-shadow layer (RN supports a single shadow; take the base layer).
|
|
181
|
+
// `SHADOW_LAYERS.rest` is a fixed `as const` tuple, so index 0 is always present.
|
|
182
|
+
const { y: shadowY, blur: shadowBlur, alpha: shadowAlpha } = SHADOW_LAYERS.rest[0];
|
|
183
|
+
const containerStyle = {
|
|
184
|
+
borderRadius: radius,
|
|
185
|
+
// iOS shadow; Android elevation needs a backgroundColor to render — it is
|
|
186
|
+
// fully covered by the absolutely-filled clip layer, so it is never visible.
|
|
187
|
+
shadowOffset: { width: 0, height: shadowY },
|
|
188
|
+
shadowOpacity: shadowAlpha,
|
|
189
|
+
shadowRadius: shadowBlur,
|
|
190
|
+
elevation: Math.round(shadowY),
|
|
191
|
+
...(Platform.OS === 'android' ? { backgroundColor: tintFill } : null),
|
|
192
|
+
...toPadding(padding),
|
|
193
|
+
};
|
|
194
|
+
const clipStyle = { borderRadius: radius };
|
|
195
|
+
const surface = (_jsxs(_Fragment, { children: [_jsxs(View, { pointerEvents: "none", style: [styles.clip, clipStyle], children: [_jsx(BlurView, { intensity: intensity, tint: "light", experimentalBlurMethod: Platform.OS === 'android' ? 'dimezisBlurView' : 'none', style: styles.fill }), _jsx(View, { style: [styles.fill, { backgroundColor: tintFill }] }), _jsx(LinearGradient, { colors: sheenColors, locations: [0, 0.3, 0.65], start: { x: 0.5, y: 0 }, end: { x: 0.5, y: 1 }, style: styles.fill })] }), _jsx(View, { style: styles.content, children: children })] }));
|
|
196
|
+
if (onPress) {
|
|
197
|
+
return (_jsx(Pressable, { onPress: onPress, style: [styles.container, containerStyle, style], children: surface }));
|
|
198
|
+
}
|
|
199
|
+
return _jsx(View, { style: [styles.container, containerStyle, style], children: surface });
|
|
200
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral design tokens (plan 022).
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth for the library's plain-data design constants —
|
|
5
|
+
* tint/saturation defaults, the blur-per-unit mapping, the drop-shadow layers
|
|
6
|
+
* and scroll-aware depth factors, the specular/press-glow geometry, the
|
|
7
|
+
* material-variant parameters, and the accessibility (reduced-transparency /
|
|
8
|
+
* increased-contrast) fills. These were previously embedded as web-local
|
|
9
|
+
* `const`s inside `liquid-glass.tsx`; they live here so BOTH the web file
|
|
10
|
+
* (`liquid-glass.tsx`) and the future React Native file
|
|
11
|
+
* (`liquid-glass.native.tsx`, plan 025) can consume ONE source of truth.
|
|
12
|
+
*
|
|
13
|
+
* ## Hard constraints (what may live here)
|
|
14
|
+
*
|
|
15
|
+
* This module is a leaf with ZERO platform coupling:
|
|
16
|
+
*
|
|
17
|
+
* - It imports nothing from the React renderer packages.
|
|
18
|
+
* - It contains NO DOM style types and NO browser-global references.
|
|
19
|
+
* - It holds only the underlying NUMBERS / COLORS / structured data. Anything
|
|
20
|
+
* inherently CSS — `mixBlendMode`, `backdrop-filter` strings, the
|
|
21
|
+
* `box-shadow` serialization (`buildDropShadow`), the `radial-gradient`
|
|
22
|
+
* templates — STAYS in the web file. The native file will serialize these
|
|
23
|
+
* same values into RN style objects.
|
|
24
|
+
*
|
|
25
|
+
* The only import is the platform-neutral {@link DisplacementMode} string-union
|
|
26
|
+
* type (no runtime, no DOM), so {@link DEFAULTS.mode} keeps its exact type.
|
|
27
|
+
*
|
|
28
|
+
* @internal Not part of the public package API pre-1.0 (eng-review decision):
|
|
29
|
+
* not re-exported from `index.ts`. The native barrel (plan 025) may import from
|
|
30
|
+
* `./tokens` directly. Exposing publicly later is additive / non-breaking.
|
|
31
|
+
*/
|
|
32
|
+
import type { DisplacementMode } from './types';
|
|
33
|
+
/** Default prop values for `<LiquidGlass>` (the parity spec). */
|
|
34
|
+
export declare const DEFAULTS: {
|
|
35
|
+
readonly displacementScale: 70;
|
|
36
|
+
readonly blurAmount: 0.0625;
|
|
37
|
+
readonly saturation: 140;
|
|
38
|
+
readonly aberrationIntensity: 2;
|
|
39
|
+
readonly cornerRadius: number | string;
|
|
40
|
+
readonly mode: DisplacementMode;
|
|
41
|
+
readonly padding: number | string;
|
|
42
|
+
readonly elasticity: 0.15;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Maps the small public `blurAmount` (default `0.0625`) onto a px backdrop blur.
|
|
46
|
+
* The upstream convention treats `blurAmount` as a rem-ish factor; we scale by
|
|
47
|
+
* 16 (1rem) so the default lands at ~1px and larger values scale linearly.
|
|
48
|
+
*/
|
|
49
|
+
export declare const BLUR_PX_PER_UNIT = 16;
|
|
50
|
+
/**
|
|
51
|
+
* Scheme-aware translucent fill layered ON TOP of the blurred backdrop when
|
|
52
|
+
* `prefers-reduced-transparency: reduce` is active and the backdrop tiers (1/2)
|
|
53
|
+
* still carry a `backdrop-filter`. It frosts the surface toward opaque so the
|
|
54
|
+
* material reads as static frosted glass rather than live lensing. Higher alpha
|
|
55
|
+
* than the tier-3 solid fill because here it composites over a blurred backdrop,
|
|
56
|
+
* not bare content. (a11y plan 013.)
|
|
57
|
+
*/
|
|
58
|
+
export declare const REDUCED_TRANSPARENCY_FILL: {
|
|
59
|
+
readonly light: "rgba(255, 255, 255, 0.75)";
|
|
60
|
+
readonly dark: "rgba(30, 30, 35, 0.75)";
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Scheme-aware solid fill for the no-`backdrop-filter` tier (3) under reduced
|
|
64
|
+
* transparency: a higher alpha than the default `0.55` solid fallback so the
|
|
65
|
+
* surface is more opaque / less transparent as the setting requests.
|
|
66
|
+
*/
|
|
67
|
+
export declare const REDUCED_TRANSPARENCY_SOLID_FILL: {
|
|
68
|
+
readonly light: "rgba(255, 255, 255, 0.92)";
|
|
69
|
+
readonly dark: "rgba(30, 30, 35, 0.92)";
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Solid border color drawn as an INSET `box-shadow` ring on the surface so the
|
|
73
|
+
* box geometry is unchanged (no layout shift) — a real `border` would grow the
|
|
74
|
+
* box or eat into padding. Scheme-aware near-opaque ink/light for a clearly
|
|
75
|
+
* delineated edge that the soft bevel alone cannot provide.
|
|
76
|
+
*/
|
|
77
|
+
export declare const CONTRAST_BORDER_COLOR: {
|
|
78
|
+
readonly light: "rgba(0, 0, 0, 0.92)";
|
|
79
|
+
readonly dark: "rgba(255, 255, 255, 0.92)";
|
|
80
|
+
};
|
|
81
|
+
/** Inset solid-border ring width (px), prepended to the surface box-shadow. */
|
|
82
|
+
export declare const CONTRAST_BORDER_WIDTH = 2;
|
|
83
|
+
/**
|
|
84
|
+
* More-opaque scheme-aware surface fill under increased contrast: it reduces
|
|
85
|
+
* the backdrop tint/show-through so foreground content reads at a higher
|
|
86
|
+
* contrast ratio. Layered over any retained blur (tiers 1/2) or used as the
|
|
87
|
+
* solid fill (tier 3). At 0.92 alpha it is at least as opaque as the
|
|
88
|
+
* reduced-transparency fills, so when BOTH a11y axes are active the contrast
|
|
89
|
+
* fill never undercuts reduced-transparency's opacity intent — contrast wants
|
|
90
|
+
* MORE opacity, not less, on every tier.
|
|
91
|
+
*/
|
|
92
|
+
export declare const CONTRAST_FILL: {
|
|
93
|
+
readonly light: "rgba(255, 255, 255, 0.92)";
|
|
94
|
+
readonly dark: "rgba(22, 22, 26, 0.92)";
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Under increased contrast, saturation is pinned to 100% (no decorative
|
|
98
|
+
* vibrancy boost) so the backdrop doesn't push color that competes with
|
|
99
|
+
* foreground text. The default `saturation` (140) is decorative and harms
|
|
100
|
+
* contrast, so it is overridden — never raised.
|
|
101
|
+
*/
|
|
102
|
+
export declare const CONTRAST_SATURATION = 100;
|
|
103
|
+
/**
|
|
104
|
+
* Content-adaptive foreground ink (plan 018). When `adaptiveTint` is on, the
|
|
105
|
+
* content layer's `color` flips with the verdict so small content (button
|
|
106
|
+
* labels, captions) stays legible: DARK ink over a bright/light backdrop, LIGHT
|
|
107
|
+
* ink over a dark backdrop. `color` is inheritable, so children that don't set
|
|
108
|
+
* their own color pick it up; children with an explicit color keep it. Best-
|
|
109
|
+
* effort legibility — see the `adaptiveTint` TSDoc limitation note.
|
|
110
|
+
*/
|
|
111
|
+
export declare const CONTENT_INK: {
|
|
112
|
+
readonly light: "rgba(255, 255, 255, 0.96)";
|
|
113
|
+
readonly dark: "rgba(17, 17, 20, 0.96)";
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Resting specular-hotspot center as a percentage of the surface box. With no
|
|
117
|
+
* pointer over the glass (or under reduced-motion) the highlight falls back to a
|
|
118
|
+
* neutral, top-biased position so the material reads like overhead light on
|
|
119
|
+
* glass rather than a flat sweep. Top-center, slightly above the box.
|
|
120
|
+
*/
|
|
121
|
+
export declare const SPECULAR_REST_X = "50%";
|
|
122
|
+
export declare const SPECULAR_REST_Y = "0%";
|
|
123
|
+
/**
|
|
124
|
+
* Peak opacity of the specular hotspot's bright core (the inner stop). Hover
|
|
125
|
+
* brightens it slightly (see `LiquidGlass`); these are the base values.
|
|
126
|
+
*/
|
|
127
|
+
export declare const SPECULAR_CORE_OPACITY: {
|
|
128
|
+
readonly rest: 0.55;
|
|
129
|
+
readonly hover: 0.85;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Radius of the specular hotspot gradient as a percentage of the surface's
|
|
133
|
+
* larger dimension. Big enough to read as a soft glassy hotspot, small enough
|
|
134
|
+
* that it tracks visibly rather than washing the whole surface.
|
|
135
|
+
*/
|
|
136
|
+
export declare const SPECULAR_RADIUS = "70%";
|
|
137
|
+
/**
|
|
138
|
+
* Peak opacity of the press-glow bloom's core when active. The glow originates
|
|
139
|
+
* at the contact point and spreads, then fades via the layer's opacity
|
|
140
|
+
* transition (gated on reduced-motion).
|
|
141
|
+
*/
|
|
142
|
+
export declare const PRESS_GLOW_OPACITY = 0.5;
|
|
143
|
+
/** Radius of the press-glow bloom as a percentage of the surface box. */
|
|
144
|
+
export declare const PRESS_GLOW_RADIUS = "60%";
|
|
145
|
+
/**
|
|
146
|
+
* The static drop-shadow (the `static` factor of 1 reproduces today's exact
|
|
147
|
+
* values) is scaled by a single multiplier per backdrop scheme. The shadow
|
|
148
|
+
* DEEPENS/DARKENS over a dark backdrop (factor > 1 → larger offset/blur + darker
|
|
149
|
+
* alpha, lifting the glass above dense scrolling content) and EASES/LIGHTENS over
|
|
150
|
+
* a light/solid one (factor < 1). Factors are deliberately subtle so the polish
|
|
151
|
+
* reads as a gentle depth shift, not a jump.
|
|
152
|
+
*
|
|
153
|
+
* Keys map directly to `BackdropScheme`; `static` is the SSR / first-paint /
|
|
154
|
+
* unsampled fallback and equals the committed baseline shadow exactly.
|
|
155
|
+
*/
|
|
156
|
+
export declare const SCROLL_SHADOW_DEPTH: {
|
|
157
|
+
/** Conservative fallback: byte-for-byte the existing static shadow. */
|
|
158
|
+
readonly static: 1;
|
|
159
|
+
/** Dark/dense backdrop ⇒ deeper, darker shadow. */
|
|
160
|
+
readonly dark: 1.35;
|
|
161
|
+
/** Light/solid backdrop ⇒ shallower, lighter shadow. */
|
|
162
|
+
readonly light: 0.7;
|
|
163
|
+
};
|
|
164
|
+
/** Base outer drop-shadow layers (offsetY, blur, alpha) at rest and on hover. */
|
|
165
|
+
export declare const SHADOW_LAYERS: {
|
|
166
|
+
readonly rest: readonly [{
|
|
167
|
+
readonly y: 8;
|
|
168
|
+
readonly blur: 24;
|
|
169
|
+
readonly alpha: 0.22;
|
|
170
|
+
}, {
|
|
171
|
+
readonly y: 1;
|
|
172
|
+
readonly blur: 4;
|
|
173
|
+
readonly alpha: 0.14;
|
|
174
|
+
}];
|
|
175
|
+
readonly hover: readonly [{
|
|
176
|
+
readonly y: 12;
|
|
177
|
+
readonly blur: 32;
|
|
178
|
+
readonly alpha: 0.28;
|
|
179
|
+
}, {
|
|
180
|
+
readonly y: 2;
|
|
181
|
+
readonly blur: 8;
|
|
182
|
+
readonly alpha: 0.18;
|
|
183
|
+
}];
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Per-variant surface parameters (plan 019). A small parameter LOOKUP — NOT a
|
|
187
|
+
* theming system — that feeds the existing surface/content styles.
|
|
188
|
+
*
|
|
189
|
+
* - `'regular'` is a pure pass-through: `saturationFactor = 1`, no scrim, and
|
|
190
|
+
* `forceNonAdaptive = false`, so the default render is byte-for-byte unchanged
|
|
191
|
+
* and `adaptiveTint` keeps working exactly as in plan 018.
|
|
192
|
+
* - `'clear'` is permanently MORE transparent: the decorative backdrop saturation
|
|
193
|
+
* is pulled back (`saturationFactor < 1` reduces the tint/vibrancy show-through)
|
|
194
|
+
* and `forceNonAdaptive` hard-disables the adaptive sampling path. A subtle
|
|
195
|
+
* dimming scrim is rendered as a CONTENT SIBLING (see the layer stack) so labels
|
|
196
|
+
* stay legible over busy media without re-introducing adaptivity.
|
|
197
|
+
*/
|
|
198
|
+
export declare const VARIANT_PARAMS: {
|
|
199
|
+
readonly regular: {
|
|
200
|
+
/** Multiplier on the requested `saturation` (1 = unchanged). */
|
|
201
|
+
readonly saturationFactor: 1;
|
|
202
|
+
/** Force the adaptive-tint path off regardless of `adaptiveTint`. */
|
|
203
|
+
readonly forceNonAdaptive: false;
|
|
204
|
+
/** Scheme-aware dimming-scrim color behind the content, or `null` for none. */
|
|
205
|
+
readonly scrim: {
|
|
206
|
+
light: string;
|
|
207
|
+
dark: string;
|
|
208
|
+
} | null;
|
|
209
|
+
};
|
|
210
|
+
readonly clear: {
|
|
211
|
+
readonly saturationFactor: 0.7;
|
|
212
|
+
readonly forceNonAdaptive: true;
|
|
213
|
+
readonly scrim: {
|
|
214
|
+
light: string;
|
|
215
|
+
dark: string;
|
|
216
|
+
} | null;
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* The frosted-glass DEFAULT fill: a faint white tint that gives the surface a
|
|
221
|
+
* subtle milky body even before any blur. ≈ `rgba(255, 255, 255, 0.10)`, the
|
|
222
|
+
* `oklch(1 0 0 / 0.1)` equivalent used by the Video.js liquid-glass reference.
|
|
223
|
+
*
|
|
224
|
+
* Defined HERE as the CONTRACT only — plan 022 does NOT apply it to any surface
|
|
225
|
+
* (that would change the committed pixel baseline). Plan 023 wires it onto the
|
|
226
|
+
* frosted DEFAULT look. Distinct in intent from the a11y
|
|
227
|
+
* {@link REDUCED_TRANSPARENCY_FILL} / {@link CONTRAST_FILL} fills, which exist
|
|
228
|
+
* to force opacity under specific OS settings.
|
|
229
|
+
*/
|
|
230
|
+
export declare const FROSTED_DEFAULT_FILL = "rgba(255, 255, 255, 0.1)";
|
|
231
|
+
/**
|
|
232
|
+
* The frosted-glass DEFAULT hairline ring: a crisp ~1px inset edge that defines
|
|
233
|
+
* the frosted surface's perimeter. `color` is a translucent white drawn as an
|
|
234
|
+
* inset ring; `width` is in px.
|
|
235
|
+
*
|
|
236
|
+
* Defined HERE as the CONTRACT only — plan 022 does NOT apply it (no surface
|
|
237
|
+
* change). Plan 023 serializes it onto the frosted DEFAULT surface (e.g. as an
|
|
238
|
+
* inset `box-shadow` ring, geometry-neutral like the existing bevel).
|
|
239
|
+
*/
|
|
240
|
+
export declare const FROSTED_HAIRLINE_RING: {
|
|
241
|
+
/** Inset ring color (translucent white). */
|
|
242
|
+
readonly color: "rgba(255, 255, 255, 0.5)";
|
|
243
|
+
/** Ring width in px (hairline). */
|
|
244
|
+
readonly width: 1;
|
|
245
|
+
};
|