/* Ribbon Wave: a folded sheet rippled by noise, striped in UV space. Give any element the class "ribbon-wave" and this script draws the wave behind that element's content. Nothing else to wire up: it sets the positioning it needs and injects its own canvas. One file drives every matching element on the page, and each can be retuned with data attributes. See readOverrides at the bottom. Load it with a single tag, anywhere on the page: Placement does not matter. It waits for the DOM and keeps watching for elements a page builder adds later. Webflow: the Assets panel does not take .js, but it does take .txt, and the asset CDN serves files without the nosniff header that would stop a script from running. So upload this as ribbon-wave.js.txt, copy the asset URL, and put the tag in Site settings > Custom code > Footer. Hosting the file anywhere else works the same way, including jsDelivr from a GitHub repo. That footer field needs a paid Site plan. If the tag saves and is gone after a refresh, that is the plan rather than the tag, and an HTML Embed on the page is the same thing by another route. Elementor: add the class under Advanced > CSS Classes, then load this from the theme or through a code snippet plugin. */ (function () { 'use strict'; // Fetched on demand. A dynamic import runs from a classic script, so the file // stays loadable with a plain tag and still gets the module build of three. var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.min.js'; var THREE = null, threeLoad = null; function loadTHREE(){ if (!threeLoad) threeLoad = import(THREE_URL).then(function(m){ THREE = m; }); return threeLoad; } // Framing per shape, so data-ribbon-form on its own is enough to switch one. const FORM_TF = { fold: {"rotX":-0.159592653589793,"rotY":-0.283592653589793,"rotZ":-2.81559265358979,"scaleX":10,"scaleY":10,"scaleZ":7,"posX":-24.3,"posY":-56.4,"posZ":-11.1,"lineAmount":425,"twistX":-0.055,"twistY":0.077,"twistZ":-0.518,"lineIsotropic":0,"lineThickness":1}, flat: {"rotX":-0.159592653589793,"rotY":-0.283592653589793,"rotZ":-2.81559265358979,"scaleX":10,"scaleY":10,"scaleZ":7,"posX":-24.3,"posY":-56.4,"posZ":-11.1,"lineAmount":425,"twistX":-0.055,"twistY":0.077,"twistZ":-0.518,"lineIsotropic":0,"lineThickness":1}, ring: {"rotX":-1.3,"rotY":0,"rotZ":0,"scaleX":2.2,"scaleY":2.2,"scaleZ":2.2,"posX":0,"posY":0,"posZ":0,"shapeRadius":150,"shapeWarp":0.12,"lineAmount":110,"twistX":0,"twistY":0.24,"twistZ":0,"lineIsotropic":1,"lineThickness":2.6}, tube: {"rotX":0.22,"rotY":0,"rotZ":-0.14,"scaleX":2.6,"scaleY":2.6,"scaleZ":2.6,"posX":0,"posY":0,"posZ":0,"shapeRadius":85,"shapeWarp":0.18,"lineAmount":170,"twistX":0.18,"twistY":0,"twistZ":-0.3,"lineIsotropic":1,"lineThickness":2.6}, sphere: {"rotX":0.18,"rotY":0,"rotZ":0,"scaleX":2.2,"scaleY":2.2,"scaleZ":2.2,"posX":0,"posY":0,"posZ":0,"shapeRadius":160,"shapeWarp":0.15,"lineAmount":150,"twistX":0,"twistY":0.2,"twistZ":0,"lineIsotropic":1,"lineThickness":2.6} }; // Every element with this class gets a wave. Add it in Webflow under the // Style panel, or in Elementor under Advanced > CSS Classes. const SELECTOR = '.ribbon-wave'; const baseCfg = { background: '#f3f0e7', minHeight: 420, // used only if the element has no height of its own // Palette: entry 0 fills the sheet, the rest are soft blobs over it. palette: [ { hex:'#6928c4', x:0.5, y:0.5, r:0 }, { hex:'#8a3ce0', x:0.66, y:0.06, r:0.52 }, { hex:'#f0a31c', x:0.42, y:0.4, r:0.18 }, { hex:'#f26f21', x:0.62, y:0.62, r:0.24 }, { hex:'#ea5ca4', x:0.45, y:0.96, r:0.3 }, { hex:'#6928c4', x:0.96, y:0.9, r:0.32 }, { hex:'#f0a31c', x:0.02, y:0.52, r:0.2 } ], hueShift: -0.0315926535897932, saturation: 1.925, contrast: 1.05, vibrance: 0, glow: 0, hueCycle: 0, // radians per second of hue drift hueSpread: 0, bgProtect: 0.25, // holds palette colours that match the background speed: 0.00004, // noise drift per millisecond phase: 2300, // starting point in the noise waveHeight: 6.051, // how far the noise lifts the sheet waveScaleX: 0.0119, waveScaleZ: 0.00799, size: 1, // multiplies the three scales below scaleX: 10, scaleY: 10, scaleZ: 7, posX: -25.2, posY: -0.6, posZ: -11.1, rotX: -0.159592653589793, rotY: -0.283592653589793, rotZ: -3.01059265358979, // radians // Twist angle per axis, and the falloff that concentrates it at one edge. twistX: -0.055, falloffX: 3.95, twistY: 0.077, falloffY: 5.85, twistZ: -0.518, falloffZ: 6.33, form: 'fold', // fold | flat | ring | tube | sphere foldRadius: 4, foldPinch: 2, foldFalloff: 9.5, shapeRadius: 150, shapeHole: 0.25, shapeSpan: 1, shapeSpiral: 0, shapeWarp: 0.12, shapeWarpScale: 2.5, shapeSeed: 1.7, lineAmount: 340, // ~108 stripes across the sheet lineThickness: 1, lineFalloff: 0.95, lineIsotropic: 0, // 1 keeps line width even on curved forms maxWidth: 1232, // reference width the line weight is tuned against depthFade: 1, vignetteInner: 0, // percent of the radius left untouched vignette: 69, // radial fade to background, percent edgeFade: 50, // left-to-right fade, percent sheetFade: 0, // dissolves the sheet's own borders, percent of UV sheetFadeMin: 15, // border feather in screen terms, percent of frame height seeThrough: false, dither: 1.5 // TPDF dither in 1/255 units, kills dark banding }; const VERT = ` uniform float u_time; uniform float u_speed; uniform vec2 u_resolution; uniform float u_twistFrequencyX; uniform float u_twistFrequencyY; uniform float u_twistFrequencyZ; uniform float u_twistPowerX; uniform float u_twistPowerY; uniform float u_twistPowerZ; uniform float u_displaceFrequencyX; uniform float u_displaceFrequencyZ; uniform float u_displaceAmount; varying vec2 v_uv; varying vec3 v_position; varying vec4 v_clipPosition; varying vec2 v_resolution; // Simplex noise, Stefan Gustavson's version, via https://www.shadertoy.com/view/43tBDr // Hash derived from xxHash (https://xxhash.com), BSD-2-Clause. float xxhash(vec2 x) { uvec2 t = floatBitsToUint(x); uint h = 0xc2b2ae3du * t.x + 0x165667b9u; h = (h << 17u | h >> 15u) * 0x27d4eb2fu; h += 0xc2b2ae3du * t.y; h = (h << 17u | h >> 15u) * 0x27d4eb2fu; h ^= h >> 15u; h *= 0x85ebca77u; h ^= h >> 13u; h *= 0xc2b2ae3du; h ^= h >> 16u; return uintBitsToFloat(h >> 9u | 0x3f800000u) - 1.0; } vec2 hash(vec2 x) { float k = 6.283185307 * xxhash(x); return vec2(cos(k), sin(k)); } float simplexNoise(in vec2 p) { const float K1 = 0.366025404; const float K2 = 0.211324865; vec2 i = floor(p + (p.x + p.y) * K1); vec2 a = p - i + (i.x + i.y) * K2; float m = step(a.y, a.x); vec2 o = vec2(m, 1.0 - m); vec2 b = a - o + K2; vec2 c = a - 1.0 + 2.0 * K2; vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0); vec3 n = h * h * h * vec3(dot(a, hash(i + 0.0)), dot(b, hash(i + o)), dot(c, hash(i + 1.0))); return dot(n, vec3(32.99)); } // Shaping function by Inigo Quilez, https://iquilezles.org/articles/functions/ // Concentrates a twist at one edge of the sheet: 1.0 at x=0, decaying fast. float expStep(float x, float n) { return exp2(-exp2(n) * pow(x, n)); } float mapLinear(float value, float min1, float max1, float min2, float max2) { return min2 + (value - min1) * (max2 - min2) / (max1 - min1); } mat4 rotationMatrix(vec3 axis, float angle) { axis = normalize(axis); float s = sin(angle); float c = cos(angle); float oc = 1.0 - c; return mat4( oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0, oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0, oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0, 0.0, 0.0, 0.0, 1.0); } // One travelling noise field lifts the sheet. This is the whole animation. vec3 displace(vec3 position, float time, float frequencyX, float frequencyZ, float amount) { float noise = simplexNoise(vec2(position.x * frequencyX + time, position.z * frequencyZ + time)); position.y += amount * noise; return position; } void main(void) { v_uv = uv; v_resolution = u_resolution; // Three twists on diagonal axes, each gated by expStep so it bites at one edge only. mat4 rotationA = rotationMatrix(vec3(0.5, 0.0, 0.5), u_twistFrequencyY * expStep(v_uv.x, u_twistPowerY)); mat4 rotationB = rotationMatrix(vec3(0.0, 0.5, 0.5), u_twistFrequencyX * expStep(v_uv.y, u_twistPowerX)); mat4 rotationC = rotationMatrix(vec3(0.5, 0.0, 0.5), u_twistFrequencyZ * expStep(v_uv.y, u_twistPowerZ)); vec3 p = displace(position.xyz, u_time * u_speed, u_displaceFrequencyX, u_displaceFrequencyZ, u_displaceAmount); v_position = p; v_position = (vec4(v_position, 1.0) * rotationA).xyz; v_position = (vec4(v_position, 1.0) * rotationB).xyz; v_position = (vec4(v_position, 1.0) * rotationC).xyz; v_clipPosition = projectionMatrix * modelViewMatrix * vec4(v_position, 1.0); gl_Position = v_clipPosition; }`; const FRAG = ` precision highp float; #define MAX_BLOBS 6 varying vec2 v_uv; varying vec3 v_position; varying vec4 v_clipPosition; varying vec2 v_resolution; uniform sampler2D u_paletteTexture; uniform float u_useTexture; uniform vec3 u_paletteBase; uniform vec3 u_blobColor[MAX_BLOBS]; uniform vec2 u_blobPos[MAX_BLOBS]; uniform float u_blobRadius[MAX_BLOBS]; uniform int u_blobCount; uniform vec3 u_clearColor; uniform float u_time; uniform float u_colorSaturation; uniform float u_colorContrast; uniform float u_colorHueShift; uniform float u_hueCycle; uniform float u_hueSpread; uniform float u_vibrance; uniform float u_glow; uniform float u_bgProtect; uniform float u_transparent; uniform float u_lineAmount; uniform float u_lineThickness; uniform float u_lineDerivativePower; uniform float u_lineIsotropic; uniform float u_maxWidth; uniform float u_depthFade; uniform float u_vignette; uniform float u_vignetteInner; uniform float u_edgeFade; uniform vec2 u_sheetFade; uniform float u_sheetFadeMin; uniform float u_dither; vec3 contrast(in vec3 v, in float a) { return (v - 0.5) * a + 0.5; } vec3 desaturate(vec3 color, float factor) { vec3 lum = vec3(0.299, 0.587, 0.114); vec3 gray = vec3(dot(lum, color)); return mix(color, gray, factor); } // Vibrance pushes colour away from its own luma, and pushes the least // saturated pixels hardest. That is what lifts the muddy middle of a palette // blend, where two saturated hues cross and average out towards grey. A plain // saturation boost cannot do it: it drives the already saturated parts into // flat slabs long before the middle catches up. vec3 vibrance(vec3 c, float amt) { if (amt == 0.0) return c; float mx = max(max(c.r, c.g), c.b); float mn = min(min(c.r, c.g), c.b); float chroma = mx - mn; float luma = dot(c, vec3(0.2126, 0.7152, 0.0722)); return clamp(mix(vec3(luma), c, 1.0 + amt * (1.0 - chroma * 0.7)), 0.0, 1.0); } // Glow lifts coverage, not colour. Lifting the colour is the obvious move and // it does almost nothing here: the palette colours already sit near the top of // the range, so a shoulder curve has no headroom to give (measured at 3% on // the default violet). What actually dilutes the field is partial coverage. // Every pixel of a hairline is mostly background, so the composite lands far // short of the palette it was built from. Pushing coverage up a shoulder curve // drives those pixels towards the palette colour itself, which reads as // vibrancy while leaving hue and saturation exactly as the palette set them. float glowCoverage(float a, float amt) { if (amt <= 0.0) return a; float g = 1.0 + amt * 4.0; float lifted = (1.0 - exp(-a * g)) / (1.0 - exp(-g)); return mix(a, lifted, clamp(amt, 0.0, 1.0)); } // https://godotshaders.com/shader/hue-shift/ (CC0) vec3 hueShift(vec3 color, float shift) { vec3 gray = vec3(0.57735); vec3 projection = gray * dot(gray, color); vec3 U = color - projection; vec3 V = cross(gray, U); return U * cos(shift) + V * sin(shift) + projection; } // The palette is evaluated at full float precision instead of being sampled // from an 8-bit gradient image, so its ramps cannot band. vec3 paletteColor(vec2 uv) { vec3 c = u_paletteBase; for (int i = 0; i < MAX_BLOBS; i++) { if (i >= u_blobCount) break; float t = length(uv - u_blobPos[i]) / max(u_blobRadius[i], 0.0001); if (t >= 1.0) continue; // Matches the stop pattern of the equivalent canvas radial gradient. float a = (t < 0.55) ? mix(1.0, 0.55, t / 0.55) : mix(0.55, 0.0, (t - 0.55) / 0.45); c = mix(c, u_blobColor[i], a); } return c; } // Triangular-PDF dither. Two uniform samples summed give a triangular // distribution, which breaks up 8-bit steps without looking like noise. // Stable per pixel value in [0,1), same shape as the dither below. Used to // dissolve the sheet's border rather than dim it. float screenHash(vec2 p) { return fract(sin(dot(p, vec2(23.1406, 2.6651))) * 12345.6789); } float ditherTPDF(vec2 p) { float a = fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); float b = fract(sin(dot(p + 17.31, vec2(26.6519, 41.7891))) * 24634.6347); return a + b - 1.0; } void main(void) { // Screen-space derivative of the U coordinate along Y. Where the sheet turns // edge-on this explodes, which is what widens and merges the lines into bands. vec2 dy = dFdy(v_uv); vec2 st = gl_FragCoord.xy / v_resolution.xy; vec3 rgb = (u_useTexture > 0.5) ? texture2D(u_paletteTexture, v_uv).rgb : paletteColor(v_uv); // Grading is for the waves. A palette entry that already sits on the // background colour is held there instead of picking up the tint, which // matters for image palettes and for any palette carrying a near-background // neutral. Distance is measured against the background, so the hold fades // out smoothly rather than cutting a hard edge. vec3 graded = contrast(rgb, u_colorContrast); graded = desaturate(graded, 1.0 - u_colorSaturation); graded = vibrance(graded, u_vibrance); // Hue can drift with time and spread across the stripes. u_time is in // milliseconds, so the drift is in radians per second. With both set the // gradient travels along the sheet instead of the whole thing shifting at // once, which is the iridescent case. float hue = u_colorHueShift + u_hueCycle * u_time * 0.001 + u_hueSpread * (v_uv.x - 0.5); graded = hueShift(graded, hue); float offBg = (u_bgProtect > 0.0001) ? smoothstep(0.0, u_bgProtect, distance(rgb, u_clearColor)) : 1.0; rgb = mix(rgb, graded, offBg); // Held aside for the transparent export, which needs the wave colour on its // own with coverage in the alpha channel instead of a background fill. vec3 wave = rgb; // Two ways to set line weight. // Stripe's original scales it by the vertical derivative of u against a fixed // reference width, which suits a near-horizontal ribbon. float ltRibbon = u_lineThickness * pow(abs(dy.s) * u_maxWidth, u_lineDerivativePower); // The even mode solves for a real pixel width instead: |sin(u*L)| rises at // rate L per unit of u, and du is u per pixel, so L*du*w/2 is the threshold // that yields a w-pixel line on any curvature, at any line count. float duIso = length(vec2(dFdx(v_uv).x, dy.s)); float ltEven = duIso * u_lineAmount * u_lineThickness * 0.5; float lineThickness = mix(ltRibbon, ltEven, u_lineIsotropic); float a = abs(sin(v_uv.x * u_lineAmount)); a = smoothstep(lineThickness, 0.0, a); a = glowCoverage(a, u_glow); float depthFade = clamp(v_clipPosition.z * 6.0, 0.0, 1.0) * u_depthFade; // Between the lines the sheet paints the background colour, so the quad // disappears into the section it sits on. float ink = a * (1.0 - depthFade); rgb = mix(u_clearColor, rgb, ink); // Vignette and edge fade run here rather than as CSS gradients so they land // before dithering; a CSS ramp over a dark ground is itself a banding source. // radial matches a CSS ellipse farthest-corner gradient, whose 100% sits at // sqrt(2) times the half-extent. float radial = length(st - 0.5) / 0.70710678; float vig = 1.0 - clamp((radial - u_vignetteInner) / max(u_vignette - u_vignetteInner, 0.0001), 0.0, 1.0); float edge = 1.0 - clamp(st.x / max(u_edgeFade, 0.0001), 0.0, 1.0); // Dissolve the sheet's own borders so the geometry edge never shows as a // seam, whatever the size and rotation are set to. // // A band measured in UV is not enough on its own. Where the wave compresses // the sheet towards edge-on, that band collapses to a couple of pixels and // the border reads as a hard cut across the lines, which is what appears at // high wave heights. u_sheetFadeMin is a fraction of the frame height, so // the wider of the two bands wins and the ramp keeps a minimum width on // screen however the sheet is stretched locally. Being a fraction of the // frame rather than a pixel count, it also survives being rendered at export // resolution. An axis with its UV fade switched off stays off, which is how // a wrapping axis on the closed shapes avoids having a gap cut into it. float sheet = 1.0; // The screen-space band is a distance, not a UV threshold. Dividing the UV // distance to the border by the UV change per pixel gives the distance to // that border in pixels, so the ramp covers the same number of pixels // wherever it lands. Inflating the UV threshold instead would fade a huge // area of the sheet wherever it is compressed, rather than a strip along // its edge: measured, that cost 118 levels in the middle of the frame. float bandPx = u_sheetFadeMin * v_resolution.y; if (u_sheetFade.x > 0.0001) { float f = smoothstep(0.0, u_sheetFade.x, v_uv.x) * smoothstep(0.0, u_sheetFade.x, 1.0 - v_uv.x); if (bandPx > 0.0) { float per = max(length(vec2(dFdx(v_uv.x), dFdy(v_uv.x))), 1e-9); f = min(f, smoothstep(0.0, bandPx, min(v_uv.x, 1.0 - v_uv.x) / per)); } sheet *= f; } if (u_sheetFade.y > 0.0001) { float f = smoothstep(0.0, u_sheetFade.y, v_uv.y) * smoothstep(0.0, u_sheetFade.y, 1.0 - v_uv.y); if (bandPx > 0.0) { float per = max(length(vec2(dFdx(v_uv.y), dFdy(v_uv.y))), 1e-9); f = min(f, smoothstep(0.0, bandPx, min(v_uv.y, 1.0 - v_uv.y) / per)); } sheet *= f; } // The sheet paints the background colour between its lines, so a border that // has faded in colour still hides whatever is behind it: the depth test is // binary and does not care that the colour went to background. On a fold // that is exactly what leaves a hard line across the far leaf, along the // near leaf's border, and it is what shows up once the wave is big enough to // swing that border into frame. Dissolving the border instead makes the // occlusion fall off with the fade: at half fade half the pixels are // dropped, so the far leaf comes through progressively. Discarding also // skips the depth write, which is the whole point. if (sheet < 0.999 && sheet < screenHash(gl_FragCoord.xy)) discard; // Same order CSS would apply: the bg overlay first, then the mask. float frame = clamp(vig * (1.0 - edge) * sheet, 0.0, 1.0); rgb = mix(u_clearColor, rgb, frame); float d = (u_dither / 255.0) * ditherTPDF(gl_FragCoord.xy); rgb += d; if (u_transparent > 0.5) { // Export path: no background fill at all, coverage goes to alpha. Output is // premultiplied, which is what the canvas expects, so line edges composite // over any colour without a dark fringe. Alpha carries the dither too, // otherwise the long faint tails band once they are composited. float alpha = clamp(ink * frame + d, 0.0, 1.0); gl_FragColor = vec4((wave + d) * alpha, alpha); } else { gl_FragColor = vec4(rgb, 1.0); } }`; function buildWaveGeometry(THREE, opts){ var W = 400, H = 400, SUBX = 128, SUBY = 256, CAP = 16; var form = opts.form || 'fold'; var geo = new THREE.PlaneGeometry(W, H, SUBX, SUBY); var pos = geo.attributes.position, uv = geo.attributes.uv; var axisX = new THREE.Vector3(1, 0, 0), axisY = new THREE.Vector3(0, 1, 0); var p = new THREE.Vector3(); var R = opts.shapeRadius, hole = opts.shapeHole, span = opts.shapeSpan; var spiral = opts.shapeSpiral, warp = opts.shapeWarp, wscale = opts.shapeWarpScale; var seed = opts.shapeSeed; var TAU = Math.PI * 2; for (var i = 0; i < pos.count; i++) { var u = uv.getX(i), v = uv.getY(i); if (form === 'fold' || form === 'flat') { p.fromBufferAttribute(pos, i); if (form === 'fold') { // Fold radius, pinched to a waist at the middle of the sheet. var r = opts.foldRadius - opts.foldPinch * Math.pow(4 * v * (1 - v), opts.foldFalloff); if (p.x < -CAP) { p.z += r; // near leaf } else if (p.x < CAP) { var t = (p.x + CAP) / (2 * CAP); // rounded edge joining them p.z = Math.cos(t * Math.PI) * r; p.x = Math.cos(t * Math.PI - Math.PI / 2) * r - CAP; } else { p.z -= r; // far leaf, mirrored back p.x = -p.x; } p.x += W / 4; } p.applyAxisAngle(axisX, -Math.PI / 2); p.applyAxisAngle(axisY, -Math.PI / 2); } else if (form === 'ring') { // u is the radius, so the stripes come out as concentric rings. var ang = v * TAU * span; var rad = R * (hole + (1 - hole) * u) + R * spiral * v * span + R * warp * shapeWarpAt(ang, u, wscale, seed); p.set(Math.cos(ang) * rad, 0, Math.sin(ang) * rad); } else if (form === 'tube') { // u wraps around the tube, so the stripes run along its length the way // they run along the ribbon. Rings instead would project to flat // vertical lines under an orthographic camera. var ta = u * TAU; var trad = R * (1 + warp * shapeWarpAt(ta, v, wscale, seed)); p.set((v - 0.5) * W, Math.sin(ta) * trad, Math.cos(ta) * trad); } else { // Sphere: u is latitude, so the stripes are lines of latitude. var polar = u * Math.PI, az = v * TAU; var srad = R * (1 + warp * shapeWarpAt(az, u, wscale, seed)); p.set(Math.sin(polar) * Math.cos(az) * srad, Math.cos(polar) * srad, Math.sin(polar) * Math.sin(az) * srad); } pos.setXYZ(i, p.x, p.y, p.z); } pos.needsUpdate = true; geo.computeVertexNormals(); return geo; } function shapeWarpAt(angle, along, scale, seed){ var n1 = Math.max(1, Math.round(scale)); var n2 = Math.max(2, Math.round(scale * 2)); return 0.55 * Math.sin(angle * n1 + seed) + 0.28 * Math.sin(angle * n2 + seed * 1.7 + 1.1) + 0.34 * Math.sin(along * scale * 1.6 + seed * 2.3); } function formClosedAxes(o){ if (o.form === 'tube') return { u: true, v: false }; if (o.form === 'sphere') return { u: false, v: true }; if (o.form === 'ring') { return { u: false, v: o.shapeSpan >= 0.999 && Math.abs(o.shapeSpiral) < 0.001 }; } return { u: false, v: false }; } // Any element carrying the class gets its own instance. Nothing is added to // your markup: the canvas is injected behind the element's own content. function mount(host){ if (host.dataset.ribbonInit) return; host.dataset.ribbonInit = '1'; const cfg = readOverrides(host, baseCfg); // Host must be a positioning context, and must be its own stacking context // so the canvas can sit behind the content without touching the content. if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.style.isolation = 'isolate'; if (host.clientHeight < 1) host.style.minHeight = cfg.minHeight + 'px'; const layer = document.createElement('div'); layer.setAttribute('aria-hidden', 'true'); layer.style.cssText = 'position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:-1;background:' + cfg.background; const canvas = document.createElement('canvas'); canvas.style.cssText = 'width:100%;height:100%;display:block'; layer.appendChild(canvas); host.insertBefore(layer, host.firstChild); if (THREE.ColorManagement) THREE.ColorManagement.enabled = false; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); if ('outputColorSpace' in renderer) renderer.outputColorSpace = THREE.LinearSRGBColorSpace; renderer.setClearColor(new THREE.Color(cfg.background), 0); const hx = h => { const n = parseInt(h.replace('#', ''), 16); return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); }; const blobs = cfg.palette.slice(1, 7); const closedAxes = formClosedAxes(cfg); const uni = { u_time: { value: cfg.phase }, u_speed: { value: cfg.speed }, u_resolution: { value: new THREE.Vector2(1, 1) }, u_paletteTexture: { value: null }, u_useTexture: { value: 0 }, u_paletteBase: { value: hx(cfg.palette[0].hex) }, u_blobColor: { value: Array.from({ length: 6 }, (_, i) => blobs[i] ? hx(blobs[i].hex) : new THREE.Vector3()) }, u_blobPos: { value: Array.from({ length: 6 }, (_, i) => new THREE.Vector2(blobs[i] ? blobs[i].x : 0, blobs[i] ? blobs[i].y : 0)) }, u_blobRadius: { value: Float32Array.from({ length: 6 }, (_, i) => blobs[i] ? blobs[i].r : 0) }, u_blobCount: { value: blobs.length }, u_clearColor: { value: hx(cfg.background) }, u_vignette: { value: cfg.vignette / 100 }, u_vignetteInner: { value: cfg.vignetteInner / 100 }, u_edgeFade: { value: cfg.edgeFade / 100 }, u_sheetFade: { value: new THREE.Vector2(closedAxes.u ? 0 : cfg.sheetFade / 100, closedAxes.v ? 0 : cfg.sheetFade / 100) }, u_sheetFadeMin: { value: cfg.sheetFadeMin / 100 }, u_lineIsotropic: { value: cfg.lineIsotropic }, u_dither: { value: cfg.dither }, u_colorSaturation: { value: cfg.saturation }, u_colorContrast: { value: cfg.contrast }, u_colorHueShift: { value: cfg.hueShift }, u_hueCycle: { value: cfg.hueCycle }, u_hueSpread: { value: cfg.hueSpread }, u_vibrance: { value: cfg.vibrance }, u_glow: { value: cfg.glow }, u_bgProtect: { value: cfg.bgProtect }, u_transparent: { value: cfg.seeThrough ? 1 : 0 }, u_displaceFrequencyX: { value: cfg.waveScaleX }, u_displaceFrequencyZ: { value: cfg.waveScaleZ }, u_displaceAmount: { value: cfg.waveHeight }, u_twistFrequencyX: { value: cfg.twistX }, u_twistFrequencyY: { value: cfg.twistY }, u_twistFrequencyZ: { value: cfg.twistZ }, u_twistPowerX: { value: cfg.falloffX }, u_twistPowerY: { value: cfg.falloffY }, u_twistPowerZ: { value: cfg.falloffZ }, u_lineAmount: { value: cfg.lineAmount }, u_lineThickness: { value: cfg.lineThickness }, u_lineDerivativePower: { value: cfg.lineFalloff }, u_maxWidth: { value: cfg.maxWidth }, u_depthFade: { value: cfg.depthFade } }; const material = new THREE.ShaderMaterial({ uniforms: uni, vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide, transparent: true, depthTest: true, // See through mode drops the background fill between the lines and adds // the layers instead of letting the nearest one win. Addition is order // independent, which the sheet needs since it overlaps itself. depthWrite: !cfg.seeThrough, blending: cfg.seeThrough ? THREE.CustomBlending : THREE.NormalBlending, blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, blendSrcAlpha: THREE.OneFactor, blendDstAlpha: THREE.OneFactor }); material.extensions = { derivatives: true }; const mesh = new THREE.Mesh(buildWaveGeometry(THREE, cfg), material); mesh.position.set(cfg.posX, cfg.posY, cfg.posZ); mesh.rotation.set(cfg.rotX, cfg.rotY, cfg.rotZ); mesh.scale.set(cfg.scaleX * cfg.size, cfg.scaleY * cfg.size, cfg.scaleZ * cfg.size); const scene = new THREE.Scene(); scene.add(mesh); // Orthographic, sized in CSS pixels, so the scene works in page units. const cam = new THREE.OrthographicCamera(0, 0, 0, 0, 1, 10000); cam.position.set(100, 0, 5000); cam.lookAt(0, 0, 0); function resize(){ const w = Math.max(1, host.clientWidth), h = Math.max(1, host.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(w, h, false); cam.left = -w / 2; cam.right = w / 2; cam.top = h / 2; cam.bottom = -h / 2; cam.updateProjectionMatrix(); uni.u_resolution.value.set(w * renderer.getPixelRatio(), h * renderer.getPixelRatio()); renderer.render(scene, cam); } const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; let visible = true, raf = 0, frame = 0, dead = false; let first = null, paused = 0, pausedAt = null, resumedAt = null, last = null, ramp = 0; const ro = new ResizeObserver(resize); const io = new IntersectionObserver(en => { visible = en[0].isIntersecting; if (visible) kick(); else halt(); }, { threshold: 0.1 }); // Page builders re-render widgets, so an instance must clean up after itself // once its host leaves the document. function destroy(){ dead = true; if (raf) cancelAnimationFrame(raf); raf = 0; ro.disconnect(); io.disconnect(); mesh.geometry.dispose(); material.dispose(); renderer.dispose(); layer.remove(); } function tick(now){ raf = 0; if (dead) return; if (!host.isConnected) { destroy(); return; } if (first === null) first = now; if (resumedAt === null) { resumedAt = now; if (pausedAt) { paused += resumedAt - pausedAt; pausedAt = null; } } last = now; // Only draw every other frame; the motion is slow enough that it reads the same. if ((frame++ & 1) === 0) { uni.u_time.value = cfg.phase + (now - first - paused) * ramp; renderer.render(scene, cam); ramp = Math.min(1, ramp + 0.016); } if (!reduced && visible) raf = requestAnimationFrame(tick); } function kick(){ if (!raf && !dead && !reduced && visible) raf = requestAnimationFrame(tick); } function halt(){ if (raf) { cancelAnimationFrame(raf); raf = 0; } resumedAt = null; pausedAt = last; } ro.observe(host); io.observe(host); resize(); if (reduced) { uni.u_time.value = cfg.phase; ramp = 1; renderer.render(scene, cam); } else { kick(); } } // Numeric settings can be overridden per element with data attributes, so one // script can drive several sections: data-ribbon-wave-height="12", // data-ribbon-line-amount="300", data-ribbon-background="#101425", // data-ribbon-colors="#a51bec,#c624ed,#ea7d85" (base first). function readOverrides(el, base){ // A page can retune any of the defaults above without editing this code by // setting window.RIBBON_WAVE_DEFAULTS to an object of the keys it wants. const out = Object.assign({}, base, window.RIBBON_WAVE_DEFAULTS || {}); // A shape switch pulls in that shape's framing first; explicit // attributes below then override it. if (el.dataset.ribbonForm && FORM_TF[el.dataset.ribbonForm]) { out.form = el.dataset.ribbonForm; Object.assign(out, FORM_TF[out.form]); } for (const k in out) { if (typeof out[k] !== 'number') continue; const v = el.dataset['ribbon' + k[0].toUpperCase() + k.slice(1)]; if (v == null || v === '') continue; const n = parseFloat(v); if (!Number.isNaN(n)) out[k] = n; } if (el.dataset.ribbonBackground) out.background = el.dataset.ribbonBackground; if (el.dataset.ribbonColors) { const hexes = el.dataset.ribbonColors.split(',').map(s => s.trim()).filter(Boolean); out.palette = out.palette.map((b, i) => hexes[i] ? Object.assign({}, b, { hex: hexes[i] }) : b); } return out; } function scan(){ if (!window.WebGL2RenderingContext) return; const els = document.querySelectorAll(SELECTOR); if (!els.length) return; loadTHREE().then(function(){ els.forEach(mount); }, function(err){ console.warn('ribbon-wave: three.js did not load from ' + THREE_URL, err); }); } if (document.readyState === 'loading') addEventListener('DOMContentLoaded', scan); else scan(); // Builders mount and re-render content after load, so keep watching for it. let rescan = 0; new MutationObserver(() => { clearTimeout(rescan); rescan = setTimeout(scan, 120); }).observe(document.documentElement, { childList: true, subtree: true }); if (window.jQuery) jQuery(window).on('elementor/frontend/init', scan); })();