Visual
Fluid Orb
Flowing color rendered with the original procedural shader
Expo code
Original web component
Rare UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
fluid-orb.tsx
import React, { useEffect, useRef, useState } from "react"
import { AppState, Text, View } from "react-native"
import { GLView, type ExpoWebGLRenderingContext } from "expo-gl"
import { useReducedMotion } from "react-native-reanimated"
const VERT = `attribute vec2 a_pos; void main(){gl_Position=vec4(a_pos,0.0,1.0);}`
const FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 u_resolution; uniform float u_time; uniform vec3 u_color;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);}
float noise(vec2 p){vec2 i=floor(p);vec2 f=fract(p);vec2 u=f*f*(3.0-2.0*f);return mix(mix(hash(i),hash(i+vec2(1.0,0.0)),u.x),mix(hash(i+vec2(0.0,1.0)),hash(i+vec2(1.0,1.0)),u.x),u.y);}
float fbm(vec2 p){float v=0.0;float a=0.6;for(int i=0;i<3;i++){v+=a*noise(p);p*=2.0;a*=0.5;}return v;}
void main(){vec2 uv=gl_FragCoord.xy/u_resolution.xy;float t=u_time*0.22;vec2 drift=vec2(sin(t)+0.6*sin(t*1.7+1.3),cos(t*0.8)+0.6*cos(t*1.3+2.1));vec2 p=vec2(uv.x*1.8,uv.y)+drift*0.7;vec2 q=vec2(fbm(p+drift),fbm(p+vec2(3.2,1.5)-drift));float f=fbm(p+1.2*q);float g=clamp(1.0-uv.y,0.0,1.0);float anchor=smoothstep(0.0,0.3,uv.y);float shade=clamp(g+(f-0.5)*0.8*anchor,0.0,1.0);vec3 white=vec3(0.99,1.0,1.0);vec3 light=mix(white,u_color,0.5);vec3 col=mix(white,light,smoothstep(0.28,0.52,shade));col=mix(col,u_color,smoothstep(0.58,0.88,shade));float edge=smoothstep(0.5,0.49,distance(uv,vec2(0.5)));gl_FragColor=vec4(col*edge,edge);}
`
export interface FluidOrbProps {
size?: number
color?: string
testID?: string
}
export function FluidOrb({
size = 240,
color = "#1A73F2",
testID,
}: FluidOrbProps) {
const reduced = useReducedMotion(),
cleanup = useRef<() => void>(() => {}),
[error, setError] = useState<string | null>(null)
useEffect(() => () => cleanup.current(), [])
function context(gl: ExpoWebGLRenderingContext) {
cleanup.current()
const shaders: WebGLShader[] = [],
buffer = gl.createBuffer(),
program = gl.createProgram()
let frame = 0,
disposed = false,
active = AppState.currentState === "active",
last = 0,
start = 0
const subscription = AppState.addEventListener("change", (next) => {
active = next === "active"
})
cleanup.current = () => {
disposed = true
cancelAnimationFrame(frame)
subscription.remove()
gl.deleteBuffer(buffer)
if (program) gl.deleteProgram(program)
shaders.forEach((s) => gl.deleteShader(s))
}
try {
if (!program || !buffer) throw new Error("GL allocation failed")
for (const [type, source] of [
[gl.VERTEX_SHADER, VERT],
[gl.FRAGMENT_SHADER, FRAG],
] as const) {
const shader = gl.createShader(type)
if (!shader) throw new Error("Shader allocation failed")
shaders.push(shader)
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
throw new Error(
gl.getShaderInfoLog(shader) ?? "Shader compilation failed"
)
gl.attachShader(program, shader)
}
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error(
gl.getProgramInfoLog(program) ?? "Shader linking failed"
)
gl.useProgram(program)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW
)
const pos = gl.getAttribLocation(program, "a_pos")
gl.enableVertexAttribArray(pos)
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0)
const resolution = gl.getUniformLocation(program, "u_resolution"),
time = gl.getUniformLocation(program, "u_time"),
tint = gl.getUniformLocation(program, "u_color")
let hex = color.replace("#", "")
if (hex.length === 3) hex = [...hex].map((x) => x + x).join("")
const n = parseInt(hex, 16)
gl.uniform3f(
tint,
((n >> 16) & 255) / 255,
((n >> 8) & 255) / 255,
(n & 255) / 255
)
function draw(now: number) {
if (disposed) return
if (!start) start = now
if (active && (reduced || now - last >= 1000 / 30)) {
last = now
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.uniform2f(
resolution,
gl.drawingBufferWidth,
gl.drawingBufferHeight
)
gl.uniform1f(time, reduced ? 0 : (now - start) / 1000)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.flush()
gl.endFrameEXP()
}
if (!reduced) frame = requestAnimationFrame(draw)
}
frame = requestAnimationFrame(draw)
} catch (e) {
cleanup.current()
setError(String(e))
}
}
return (
<View
testID={testID}
accessible
accessibilityLabel="Fluid color orb"
style={{
width: size,
height: size,
alignSelf: "center",
borderRadius: size / 2,
overflow: "hidden",
}}
>
{error ? (
<Text style={{ color: "#aaa", padding: 16 }} accessibilityRole="alert">
GL preview unavailable
</Text>
) : (
<GLView
key={`${color}-${reduced}`}
style={{ flex: 1 }}
onContextCreate={context}
/>
)}
</View>
)
}
export default FluidOrb
Give the gestures and motion a run on a real Android phone.