|
| 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