Skip to content

Commit 06bb09a

Browse files
committed
feat: add shader-based notification banners
1 parent 0bb8922 commit 06bb09a

9 files changed

Lines changed: 1230 additions & 0 deletions

File tree

example/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import AiShimmerScreen from './screens/AiShimmerScreen';
2828
import AiOrbScreen from './screens/AiOrbScreen';
2929
import AiVoiceScreen from './screens/AiVoiceScreen';
3030
import HoloFoilScreen from './screens/HoloFoilScreen';
31+
import BannersScreen from './screens/BannersScreen';
3132
import type { RootStackParamList } from './types';
3233

3334
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -102,6 +103,7 @@ export default function App() {
102103
<Stack.Screen name="AiOrbExample" component={AiOrbScreen} />
103104
<Stack.Screen name="AiVoiceExample" component={AiVoiceScreen} />
104105
<Stack.Screen name="HoloFoilExample" component={HoloFoilScreen} />
106+
<Stack.Screen name="BannersExample" component={BannersScreen} />
105107
</Stack.Navigator>
106108
</NavigationContainer>
107109
</GestureHandlerRootView>
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { StyleSheet, Text, View, type ViewProps } from 'react-native';
2+
import { ShaderView } from 'react-native-effects';
3+
4+
type Props = ViewProps & {
5+
/** Small caption line. */
6+
caption?: string;
7+
/** Large title line. */
8+
title?: string;
9+
};
10+
11+
/**
12+
* The iOS birthday-notification banner: glitter-dust fireworks popping at
13+
* random spots in the dark, behind the notification text. Each burst is an
14+
* expanding sphere of twinkling sparkle cells — no particles, just a hashed
15+
* glitter field gated by the shell's radius — with a brief white flash at
16+
* ignition, a slow downward drift, and a dusty palette (olive, mauve, pink,
17+
* silver, gold) picked per burst.
18+
*/
19+
export default function BirthdayFireworks({
20+
caption = 'Birthday · Found in Contacts',
21+
title = 'Today, Jun 9',
22+
style,
23+
...rest
24+
}: Props) {
25+
return (
26+
<View style={[styles.card, style]} {...rest}>
27+
<ShaderView
28+
fragmentShader={FIREWORKS_SHADER}
29+
style={StyleSheet.absoluteFill}
30+
/>
31+
<View style={styles.textWrap} pointerEvents="none">
32+
<Text style={styles.caption}>{caption}</Text>
33+
<Text style={styles.title}>{title}</Text>
34+
</View>
35+
</View>
36+
);
37+
}
38+
39+
const FIREWORKS_SHADER = /* wgsl */ `
40+
struct Uniforms {
41+
resolution: vec4<f32>,
42+
time: vec4<f32>,
43+
color0: vec4<f32>,
44+
color1: vec4<f32>,
45+
params0: vec4<f32>,
46+
params1: vec4<f32>,
47+
live: vec4<f32>,
48+
};
49+
@group(0) @binding(0) var<uniform> u: Uniforms;
50+
51+
fn hash21(p: vec2<f32>) -> f32 {
52+
var p3 = fract(vec3<f32>(p.xyx) * 0.1031);
53+
p3 = p3 + dot(p3, p3.yzx + 33.33);
54+
return fract((p3.x + p3.y) * p3.z);
55+
}
56+
57+
@fragment
58+
fn main(@location(0) ndc: vec2<f32>) -> @location(0) vec4<f32> {
59+
let t = u.time.x;
60+
let aspect = u.resolution.z;
61+
let uv = ndc * 0.5 + 0.5;
62+
// x in [0, aspect], y in [0, 1] — square units so bursts stay round.
63+
let p = vec2<f32>(uv.x * aspect, uv.y);
64+
65+
// Near-black notification glass.
66+
var col = vec3<f32>(0.027, 0.027, 0.031);
67+
68+
// Vivid party palette: lime, violet, hot pink, gold, cyan, silver.
69+
var palette = array<vec3<f32>, 6>(
70+
vec3<f32>(0.55, 0.95, 0.35),
71+
vec3<f32>(0.72, 0.42, 1.00),
72+
vec3<f32>(1.00, 0.40, 0.65),
73+
vec3<f32>(1.00, 0.80, 0.30),
74+
vec3<f32>(0.40, 0.85, 1.00),
75+
vec3<f32>(0.88, 0.88, 0.95)
76+
);
77+
78+
// Six launchers on offset clocks → a few distinct balls in flight, with
79+
// black space between them like the real notification.
80+
for (var i = 0; i < 6; i = i + 1) {
81+
let fi = f32(i);
82+
let period = 2.9 + 1.7 * fract(fi * 0.6180339);
83+
let clock = t / period + fract(fi * 0.7548776);
84+
let prog = fract(clock);
85+
let gen = floor(clock);
86+
87+
// Fresh randomness every cycle of every launcher.
88+
let seed = vec2<f32>(gen * 1.93 + fi * 17.0, fi * 7.31 - gen * 0.71);
89+
90+
// Some cycles stay dark so the rhythm never feels mechanical.
91+
if (hash21(seed + 9.9) > 0.78) {
92+
continue;
93+
}
94+
95+
// Anywhere on the card, edges included — offscreen clipping looks natural.
96+
let center = vec2<f32>(
97+
hash21(seed + 1.1) * aspect,
98+
mix(0.10, 0.90, hash21(seed + 2.2))
99+
);
100+
101+
// Fast pop, then coast — most of the expansion lands in the first third.
102+
let ease = 1.0 - pow(1.0 - prog, 4.5);
103+
let maxR = 0.36 + 0.34 * hash21(seed + 3.3);
104+
let radius = maxR * (0.10 + 0.90 * ease);
105+
let fade = smoothstep(1.0, 0.5, prog);
106+
107+
let rel = p - center;
108+
let d = length(rel);
109+
110+
// Ignition flash at the heart of the burst.
111+
let flash = exp(-d * d / (maxR * maxR * 0.012)) * smoothstep(0.14, 0.0, prog);
112+
col = col + vec3<f32>(1.0, 0.97, 0.9) * flash * 0.6;
113+
114+
if (d > radius * 1.05) {
115+
continue;
116+
}
117+
118+
// Real fireworks are hollow shells: stars fly out along fixed directions,
119+
// so we grid POLAR space (spokes x radial layers). Each cell owns one
120+
// star at a fixed fraction of the shell radius — as the shell grows the
121+
// star travels outward along its spoke, and existence probability rises
122+
// toward the rim so the outskirts are densest.
123+
let SPOKES = 56.0;
124+
let LAYERS = 11.0;
125+
let a01 = fract(atan2(rel.y, rel.x) * 0.15915494 + 1.0);
126+
let q = d / max(radius, 1e-4);
127+
128+
let cellA = floor(a01 * SPOKES);
129+
let cellQ = floor(q * LAYERS);
130+
let cellId = vec2<f32>(cellA + fi * 61.0, cellQ + gen * 13.0);
131+
let rnd = hash21(cellId);
132+
133+
// Rim-heavy existence: sparse core trail, dense shell edge.
134+
let qc = (cellQ + 0.5) / LAYERS;
135+
let exists = step(rnd, 0.12 + 0.88 * smoothstep(0.35, 0.95, qc));
136+
137+
if (exists > 0.5) {
138+
// The star's own position: jittered inside its polar cell, drooping
139+
// under gravity as the burst ages.
140+
let ja = (hash21(cellId + 5.5) - 0.5) * 0.8;
141+
let jq = (hash21(cellId + 8.8) - 0.5) * 0.8;
142+
let starAng = ((cellA + 0.5 + ja) / SPOKES) * 6.2831853;
143+
let starQ = (cellQ + 0.5 + jq) / LAYERS;
144+
var starPos = center + vec2<f32>(cos(starAng), sin(starAng)) * starQ * radius;
145+
starPos.y = starPos.y - prog * prog * 0.07 * (0.5 + rnd);
146+
147+
let toStar = p - starPos;
148+
let size = 16000.0 + 26000.0 * hash21(cellId + 2.7);
149+
let spark = exp(-dot(toStar, toStar) * size);
150+
151+
// Hard twinkle — glitter, not haze.
152+
let rnd2 = hash21(cellId + 47.0);
153+
let twinkle = 0.2 + 0.8 *
154+
pow(0.5 + 0.5 * sin(t * (3.0 + 6.0 * rnd2) + rnd2 * 40.0), 3.0);
155+
156+
// One dominant color per burst, with a second accent color sprinkled in.
157+
let pickA = i32(hash21(seed + 4.4) * 5.999);
158+
let pickB = i32(hash21(seed + 6.6) * 5.999);
159+
var sparkCol = palette[select(pickA, pickB, rnd2 > 0.72)];
160+
// The very brightest grains glint white.
161+
sparkCol = mix(sparkCol, vec3<f32>(1.0), step(0.95, rnd2) * 0.7);
162+
163+
// Rim stars burn brightest.
164+
let rimBoost = 0.45 + 0.85 * smoothstep(0.4, 1.0, starQ);
165+
col = col + sparkCol * spark * twinkle * fade * rimBoost * 2.4;
166+
}
167+
}
168+
169+
// Keep the banner glassy: gentle ceiling so text always wins.
170+
col = col / (1.0 + col * 0.45);
171+
172+
return vec4<f32>(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
173+
}
174+
`;
175+
176+
const styles = StyleSheet.create({
177+
card: {
178+
height: 122,
179+
borderRadius: 30,
180+
overflow: 'hidden',
181+
backgroundColor: '#070708',
182+
justifyContent: 'center',
183+
},
184+
textWrap: {
185+
paddingHorizontal: 26,
186+
},
187+
caption: {
188+
color: 'rgba(235, 235, 245, 0.72)',
189+
fontSize: 16,
190+
fontWeight: '500',
191+
letterSpacing: 0.1,
192+
marginBottom: 4,
193+
},
194+
title: {
195+
color: '#fff',
196+
fontSize: 36,
197+
fontWeight: '800',
198+
letterSpacing: 0.2,
199+
},
200+
});

0 commit comments

Comments
 (0)