01
THE_STAGE
LIVE // WEBGL
RAYS
ORIGIN top-right
YOUR CONFIG [+]
<LabSideRays :speed="2.5" :intensity="2" :spread="2" :tilt="0" :saturation="1.5" :blend="0.75" :falloff="1.6" :opacity="1" ray-color1="#EAB308" ray-color2="#96c8ff" origin="top-right" />
THE WHOLE COMPONENT [+]
<template>
<div ref="containerRef" class="side-rays" aria-hidden="true"></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
// @ts-ignore — ogl has no bundled types
import { Renderer, Program, Mesh, Triangle } from "ogl"
// Vue/OGL port of React Bits' Side Rays
// https://reactbits.dev/backgrounds/side-rays
const props = withDefaults(defineProps<{
speed?: number
rayColor1?: string
rayColor2?: string
intensity?: number
spread?: number
origin?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
tilt?: number
saturation?: number
blend?: number
falloff?: number
opacity?: number
}>(), {
speed: 2.5,
rayColor1: "#EAB308",
rayColor2: "#96c8ff",
intensity: 2,
spread: 2,
origin: "top-right",
tilt: 0,
saturation: 1.5,
blend: 0.75,
falloff: 1.6,
opacity: 1.0,
})
const containerRef = ref<HTMLDivElement | null>(null)
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return m
? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255]
: [1, 1, 1]
}
const originToFlip = (origin: string): [number, number] => {
switch (origin) {
case "top-left": return [1, 0]
case "bottom-right": return [0, 1]
case "bottom-left": return [1, 1]
default: return [0, 0] // top-right
}
}
const vertex = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}`
const fragment = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform float iSpeed;
uniform vec3 iRayColor1;
uniform vec3 iRayColor2;
uniform float iIntensity;
uniform float iSpread;
uniform float iFlipX;
uniform float iFlipY;
uniform float iTilt;
uniform float iSaturation;
uniform float iBlend;
uniform float iFalloff;
uniform float iOpacity;
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord, float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
float cosAngle = dot(normalize(sourceToCoord), rayRefDirection);
return clamp(
(0.45 + 0.15 * sin(cosAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-cosAngle * seedB + iTime * speed)),
0.0, 1.0) *
clamp((iResolution.x - length(sourceToCoord)) / iResolution.x, 0.5, 1.0);
}
void main() {
vec2 fragCoord = gl_FragCoord.xy;
if (iFlipX > 0.5) fragCoord.x = iResolution.x - fragCoord.x;
if (iFlipY > 0.5) fragCoord.y = iResolution.y - fragCoord.y;
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 rayPos = vec2(iResolution.x * 1.1, -0.5 * iResolution.y);
float tiltRad = iTilt * 3.14159265 / 180.0;
float cs = cos(tiltRad);
float sn = sin(tiltRad);
vec2 rel = coord - rayPos;
vec2 tiltedCoord = vec2(rel.x * cs - rel.y * sn, rel.x * sn + rel.y * cs) + rayPos;
float halfSpread = iSpread * 0.275;
vec2 rayRefDir1 = normalize(vec2(cos(0.785398 + halfSpread), sin(0.785398 + halfSpread)));
vec2 rayRefDir2 = normalize(vec2(cos(0.785398 - halfSpread), sin(0.785398 - halfSpread)));
vec4 rays1 = vec4(iRayColor1, 1.0) * rayStrength(rayPos, rayRefDir1, tiltedCoord, 36.2214, 21.11349, iSpeed);
vec4 rays2 = vec4(iRayColor2, 1.0) * rayStrength(rayPos, rayRefDir2, tiltedCoord, 22.3991, 18.0234, iSpeed * 0.2);
vec4 color = rays1 * (1.0 - iBlend) * 0.9 + rays2 * iBlend * 0.9;
float distanceToLight = length(fragCoord.xy - vec2(rayPos.x, iResolution.y - rayPos.y)) / iResolution.y;
float brightness = iIntensity * 0.4 / pow(max(distanceToLight, 0.001), iFalloff);
color.rgb *= brightness;
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(gray), color.rgb, iSaturation);
color.a = max(color.r, max(color.g, color.b)) * iOpacity;
gl_FragColor = color;
}`
let renderer: any
let uniforms: any
let raf = 0
let cleanup: (() => void) | undefined
let io: IntersectionObserver | undefined
function start() {
const el = containerRef.value
if (!el || renderer) return
renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio, 2), alpha: true })
const gl = renderer.gl
gl.canvas.style.width = "100%"
gl.canvas.style.height = "100%"
el.appendChild(gl.canvas)
const [flipX, flipY] = originToFlip(props.origin)
uniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] },
iSpeed: { value: props.speed },
iRayColor1: { value: hexToRgb(props.rayColor1) },
iRayColor2: { value: hexToRgb(props.rayColor2) },
iIntensity: { value: props.intensity },
iSpread: { value: props.spread },
iFlipX: { value: flipX },
iFlipY: { value: flipY },
iTilt: { value: props.tilt },
iSaturation: { value: props.saturation },
iBlend: { value: props.blend },
iFalloff: { value: props.falloff },
iOpacity: { value: props.opacity },
}
const program = new Program(gl, { vertex, fragment, uniforms })
const mesh = new Mesh(gl, { geometry: new Triangle(gl), program })
const updateSize = () => {
renderer.dpr = Math.min(window.devicePixelRatio, 2)
renderer.setSize(el.clientWidth, el.clientHeight)
uniforms.iResolution.value = [el.clientWidth * renderer.dpr, el.clientHeight * renderer.dpr]
}
window.addEventListener("resize", updateSize)
updateSize()
const loop = (t: number) => {
uniforms.iTime.value = t * 0.001
renderer.render({ scene: mesh })
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
cleanup = () => {
cancelAnimationFrame(raf)
window.removeEventListener("resize", updateSize)
gl.getExtension("WEBGL_lose_context")?.loseContext()
gl.canvas.remove()
renderer = undefined
uniforms = undefined
}
}
function stop() {
cleanup?.()
cleanup = undefined
}
// props flow straight into the uniforms — no rebuild needed
watch(props, () => {
if (!uniforms) return
const [flipX, flipY] = originToFlip(props.origin)
uniforms.iSpeed.value = props.speed
uniforms.iRayColor1.value = hexToRgb(props.rayColor1)
uniforms.iRayColor2.value = hexToRgb(props.rayColor2)
uniforms.iIntensity.value = props.intensity
uniforms.iSpread.value = props.spread
uniforms.iFlipX.value = flipX
uniforms.iFlipY.value = flipY
uniforms.iTilt.value = props.tilt
uniforms.iSaturation.value = props.saturation
uniforms.iBlend.value = props.blend
uniforms.iFalloff.value = props.falloff
uniforms.iOpacity.value = props.opacity
})
onMounted(() => {
// only burn GPU while the rays are actually on screen
io = new IntersectionObserver(
([entry]) => (entry.isIntersecting ? start() : stop()),
{ threshold: 0.1 },
)
if (containerRef.value) io.observe(containerRef.value)
})
onBeforeUnmount(() => {
io?.disconnect()
stop()
})
</script>
<style scoped>
.side-rays {
width: 100%;
height: 100%;
position: relative;
pointer-events: none;
overflow: hidden;
}
</style>
SOURCE: ported from reactbits.dev/backgrounds/side-rays (React) to Vue 3 + OGL. Same shader, Vue lifecycle.
02
THE_DIVE
The same shader as a scene: sunlight through water. Blue rays from the surface, a depth gradient behind them, a few CSS bubbles in front. Nothing else changed, it is all in the uniforms.
PRESET // UNDERWATER
THE DEEP