AI Blob
Installation
npx shadcn@latest add https://uibrik.com/r/ai-blob.jsonAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/ai-blob/ai-blob.tsx"use client"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { cn } from "@/lib/utils"; export type AIBlobVariation = | "sphere-spiral" // Exact reference: individual 3D helical dots wound around an oval sphere | "double-spiral" // Dual intertwined 3D helixes | "rings" // Gyroscopic 3D orbital rings | "torus" // 3D toroidal vortex of dots | "blob"; // 3D breathing Fibonacci sphere of dots export interface AIBlobProps { /** * Active 3D dotted variation. * Default: "sphere-spiral" */ mode?: AIBlobVariation | "spiral" | "wave" | "ring"; /** * Tailwind classes to style the container (e.g. `size-8`, `size-44`, `text-zinc-700`). */ className?: string; /** * Additional inline styles. */ style?: React.CSSProperties; /** * Explicit base neutral color override. If omitted, inherits from Tailwind (e.g. `text-zinc-700`). */ color?: string; /** * Explicit bright neutral highlight color (e.g. "#ffffff", "#f4f4f5"). */ highlightColor?: string; /** * Number of dots. * Default: 95 (creates the visible gap spacing between dots shown in the reference) */ dotCount?: number; /** * Size of the dots in pixels. * Can be a single number (e.g. `dotSize={5}`) or a depth range `[min, max]` (e.g. `dotSize={[2, 6]}`). * If omitted, scales smoothly with container size (e.g. larger on `size-44`, compact on `size-8`). */ dotSize?: number | [number, number]; /** * Animation rotation speed multiplier. * Default: 1.0 */ speed?: number; /** * Blinking speed of the traveling signal nodes along the spiral. * - `0`: Disables blinking (clean static dots) * - `0.5`: Slow, calm pulse * - `1.0`: Default pulse * - `2.0+`: Fast rapid blinking * Default: 1.0 */ blinkSpeed?: number; /** * 3D tilt pitch angle in degrees. * Default: 18 */ tilt?: number; /** * Whether clicking the blob cycles through the variations with a smooth morph. * Default: true */ interactive?: boolean; /** * Callback fired when the active variation changes. */ onModeChange?: (newMode: AIBlobVariation) => void; } const VARIATIONS: AIBlobVariation[] = [ "sphere-spiral", "double-spiral", "rings", "torus", "blob", ]; function normalizeMode(m?: string): AIBlobVariation { if (m === "spiral") return "sphere-spiral"; if (m === "wave") return "double-spiral"; if (m === "ring") return "rings"; if (VARIATIONS.includes(m as AIBlobVariation)) return m as AIBlobVariation; return "sphere-spiral"; } // ─── Shaders ────────────────────────────────────────────────────────────────── const VERT = /* glsl */ ` precision mediump float; attribute float a_idx; // 0.0 to N-1 attribute float a_seed; uniform float u_time; uniform float u_total; uniform vec4 u_w0; // weights (sphere-spiral, double-spiral, rings, torus) uniform float u_w_blob; // weight for blob uniform float u_pitch; // 3D tilt uniform float u_dpr; uniform vec2 u_dot_size; // vec2(minSize, maxSize) uniform float u_blink_speed; // blinking speed multiplier varying float v_depth; // 0 = back, 1 = front varying float v_node; // bright traveling signal dot flag varying float v_seed; #define PI 3.14159265359 void main() { float u = a_idx / (u_total - 1.0); // 0.0 to 1.0 along the spiral float t = u_time; // ══════════════════════════════════════════════════════════════════════════ // Variation 0: Sphere Spiral (Exact match to reference image) // Clean spaced dots winding in 4.2 turns around an oval sphere // ══════════════════════════════════════════════════════════════════════════ float lat0 = (u - 0.5) * PI; // -PI/2 to +PI/2 float y0 = sin(lat0) * 0.44; float r0 = cos(lat0) * 0.40; float turns0 = 4.2; float th0 = u * turns0 * 2.0 * PI + t * 1.5; vec3 p0 = vec3(r0 * cos(th0), y0, r0 * sin(th0)); // ══════════════════════════════════════════════════════════════════════════ // Variation 1: Double Spiral (Dual intertwined 3D helixes) // ══════════════════════════════════════════════════════════════════════════ float isSecond = step(0.5, mod(a_idx, 2.0)); float u1 = (floor(a_idx * 0.5) * 2.0) / (u_total - 1.0); float lat1 = (u1 - 0.5) * PI; float y1 = sin(lat1) * 0.44; float r1 = cos(lat1) * 0.40; float th1 = u1 * 3.8 * 2.0 * PI + (isSecond * PI) + t * 1.4; vec3 p1 = vec3(r1 * cos(th1), y1, r1 * sin(th1)); // ══════════════════════════════════════════════════════════════════════════ // Variation 2: Orbital Gyroscope Rings (3 horizontal rings of dots) // ══════════════════════════════════════════════════════════════════════════ float ringBand = floor(u * 3.0); float bandU = fract(u * 3.0); float lat2 = (ringBand - 1.0) * 0.72; float y2 = sin(lat2) * 0.40; float r2 = cos(lat2) * 0.41; float ringDir = (ringBand == 1.0) ? -1.0 : 1.0; float th2 = bandU * 2.0 * PI + t * (1.3 + ringBand * 0.4) * ringDir; vec3 p2 = vec3(r2 * cos(th2), y2, r2 * sin(th2)); // ══════════════════════════════════════════════════════════════════════════ // Variation 3: Torus Vortex (3D donut spiral of dots) // ══════════════════════════════════════════════════════════════════════════ float torusR = 0.26; float torusTube = 0.12; float th3 = u * 2.0 * PI * 5.0 + t * 2.0; float phi3 = u * 2.0 * PI + t * 0.8; float x3 = (torusR + torusTube * cos(th3)) * cos(phi3); float z3 = (torusR + torusTube * cos(th3)) * sin(phi3); float y3 = torusTube * sin(th3); vec3 p3 = vec3(x3, y3, z3); // ══════════════════════════════════════════════════════════════════════════ // Variation 4: Pulsing Sphere (3D breathing Fibonacci sphere of dots) // ══════════════════════════════════════════════════════════════════════════ float phi4 = a_idx * 2.3999632; float y4 = 1.0 - (a_idx / (u_total - 1.0)) * 2.0; float r4 = sqrt(max(0.0, 1.0 - y4 * y4)); float waveMod = 1.0 + sin(phi4 * 2.0 - t * 2.8) * 0.08 + cos(y4 * 6.0 + t * 2.0) * 0.05; vec3 p4 = vec3(cos(phi4) * r4 * 0.38 * waveMod, y4 * 0.38 * waveMod, sin(phi4) * r4 * 0.38 * waveMod); // Smooth linear blend across all 5 variations vec3 p = p0 * u_w0.x + p1 * u_w0.y + p2 * u_w0.z + p3 * u_w0.w + p4 * u_w_blob; // 3D Pitch Rotation (tilt forward around X-axis so we see inside the coils) float cp = cos(u_pitch); float sp = sin(u_pitch); vec3 rotP = vec3( p.x, p.y * cp - p.z * sp, p.y * sp + p.z * cp ); // Depth: -0.42 (back) to +0.42 (front) -> [0, 1] float depth = clamp((rotP.z + 0.38) / 0.76, 0.0, 1.0); v_depth = depth; // Screen projection gl_Position = vec4(rotP.x * 2.15, rotP.y * 2.15, rotP.z, 1.0); // Traveling signal nodes with controllable blink speed float waveNode = sin(u * 22.0 - t * 4.2 * u_blink_speed); float isNode = u_blink_speed > 0.001 ? step(0.92, waveNode) : 0.0; v_node = isNode; // Dot size with user-controllable range and 3D depth scaling float baseSize = mix(u_dot_size.x, u_dot_size.y, pow(depth, 1.3)); float nodeBonus = isNode * (u_dot_size.y * 0.45); gl_PointSize = clamp((baseSize + nodeBonus) * u_dpr, 1.0, 64.0); v_seed = a_seed; } `; const FRAG = /* glsl */ ` precision mediump float; uniform vec3 u_color_base; // deep neutral (e.g. #3f3f46) uniform vec3 u_color_highlight; // neutral highlight (#ffffff) varying float v_depth; // 0 = back, 1 = front varying float v_node; // 1.0 = bright traveling node varying float v_seed; void main() { vec2 uv = gl_PointCoord - 0.5; float d = length(uv); // Crisp circular dot with clean edges float shape = smoothstep(0.5, 0.38, d); if (shape < 0.01) discard; // Front dots are bright neutral; back dots are muted vec3 col = mix(u_color_base, u_color_highlight, pow(v_depth, 1.5)); // Signal nodes light up with highlight color col = mix(col, u_color_highlight, v_node * 0.95); // Alpha depth attenuation: front dots crisp and opaque, back dots atmospheric float alpha = shape * mix(0.24, 0.98, v_depth); if (v_node > 0.5) alpha = shape * 1.0; // Premultiplied alpha output gl_FragColor = vec4(col * alpha, alpha); } `; // ─── Neutral Color Parser ───────────────────────────────────────────────────── let _colorCtx: CanvasRenderingContext2D | null = null; function parseNeutral(colorStr: string, fallback: [number, number, number]): [number, number, number] { if (typeof document === "undefined" || !colorStr) return fallback; try { if (!_colorCtx) { const cvs = document.createElement("canvas"); cvs.width = 1; cvs.height = 1; _colorCtx = cvs.getContext("2d", { willReadFrequently: true }); } if (!_colorCtx) return fallback; _colorCtx.clearRect(0, 0, 1, 1); _colorCtx.fillStyle = colorStr; _colorCtx.fillRect(0, 0, 1, 1); const data = _colorCtx.getImageData(0, 0, 1, 1).data; if (data[3] === 0 && colorStr !== "transparent") return fallback; const gray = (data[0] * 0.299 + data[1] * 0.587 + data[2] * 0.114) / 255; return [gray, gray, gray]; } catch { return fallback; } } // ─── Dot Size Calculator ────────────────────────────────────────────────────── function resolveDotSize(dotSize?: number | [number, number], containerW: number = 32): [number, number] { if (Array.isArray(dotSize)) { return [dotSize[0], dotSize[1]]; } if (typeof dotSize === "number") { return [dotSize * 0.52, dotSize]; } // Auto-scale dot size gracefully with container size const scale = Math.max(0.5, Math.min(containerW / 32, 6.0)); const powerScale = Math.pow(scale, 0.65); return [1.8 * powerScale, 3.8 * powerScale]; } // ─── Component ──────────────────────────────────────────────────────────────── export default function AIBlob({ mode = "sphere-spiral", className, style, color, highlightColor, dotCount = 95, dotSize, speed = 1.0, blinkSpeed = 1.0, tilt = 18, interactive = true, onModeChange, }: AIBlobProps) { const containerRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const [activeMode, setActiveMode] = useState<AIBlobVariation>(normalizeMode(mode)); // A new `mode` prop resets the shown mode (adjusted during render, not in an effect). const [prevMode, setPrevMode] = useState(mode); if (mode !== prevMode) { setPrevMode(mode); setActiveMode(normalizeMode(mode)); } const handleToggle = useCallback(() => { if (!interactive) return; const curIdx = VARIATIONS.indexOf(activeMode); const nextMode = VARIATIONS[(curIdx + 1) % VARIATIONS.length]; setActiveMode(nextMode); onModeChange?.(nextMode); }, [interactive, activeMode, onModeChange]); const targetWeights = useMemo<[number, number, number, number, number]>(() => { switch (activeMode) { case "sphere-spiral": return [1, 0, 0, 0, 0]; case "double-spiral": return [0, 1, 0, 0, 0]; case "rings": return [0, 0, 1, 0, 0]; case "torus": return [0, 0, 0, 1, 0]; case "blob": return [0, 0, 0, 0, 1]; } }, [activeMode]); useEffect(() => { const container = containerRef.current; const canvas = canvasRef.current; if (!container || !canvas) return; const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: true, antialias: true, }); if (!gl) return; function createShader(type: number, src: string) { const s = gl!.createShader(type)!; gl!.shaderSource(s, src); gl!.compileShader(s); return s; } const prog = gl.createProgram()!; gl.attachShader(prog, createShader(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, createShader(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog); gl.useProgram(prog); // Generate indices and seeds const idxArr = new Float32Array(dotCount); const seedArr = new Float32Array(dotCount); for (let i = 0; i < dotCount; i++) { idxArr[i] = i; seedArr[i] = Math.random(); } const bufIdx = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, bufIdx); gl.bufferData(gl.ARRAY_BUFFER, idxArr, gl.STATIC_DRAW); const aIdx = gl.getAttribLocation(prog, "a_idx"); gl.enableVertexAttribArray(aIdx); gl.vertexAttribPointer(aIdx, 1, gl.FLOAT, false, 0, 0); const bufSeed = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, bufSeed); gl.bufferData(gl.ARRAY_BUFFER, seedArr, gl.STATIC_DRAW); const aSeed = gl.getAttribLocation(prog, "a_seed"); gl.enableVertexAttribArray(aSeed); gl.vertexAttribPointer(aSeed, 1, gl.FLOAT, false, 0, 0); const uTime = gl.getUniformLocation(prog, "u_time"); const uTotal = gl.getUniformLocation(prog, "u_total"); const uW0 = gl.getUniformLocation(prog, "u_w0"); const uWBlob = gl.getUniformLocation(prog, "u_w_blob"); const uPitch = gl.getUniformLocation(prog, "u_pitch"); const uDpr = gl.getUniformLocation(prog, "u_dpr"); const uDotSize = gl.getUniformLocation(prog, "u_dot_size"); const uBlink = gl.getUniformLocation(prog, "u_blink_speed"); const uColorBase = gl.getUniformLocation(prog, "u_color_base"); const uColorHigh = gl.getUniformLocation(prog, "u_color_highlight"); gl.uniform1f(uTotal, dotCount); gl.uniform1f(uPitch, (tilt * Math.PI) / 180); gl.uniform1f(uBlink, blinkSpeed); // Pure neutral colors const computed = window.getComputedStyle(container); const resolvedColor = color || computed.color || "#52525b"; const baseGray = parseNeutral(resolvedColor, [0.32, 0.32, 0.34]); const highGray = highlightColor ? parseNeutral(highlightColor, [0.98, 0.98, 0.98]) : ([0.98, 0.98, 0.98] as [number, number, number]); gl.uniform3f(uColorBase, baseGray[0], baseGray[1], baseGray[2]); gl.uniform3f(uColorHigh, highGray[0], highGray[1], highGray[2]); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); let dpr = 1; function resize(wPx: number, hPx: number) { dpr = Math.min(window.devicePixelRatio || 1, 2); const W = Math.max(1, Math.round(wPx * dpr)); const H = Math.max(1, Math.round(hPx * dpr)); canvas!.width = W; canvas!.height = H; canvas!.style.width = `${wPx}px`; canvas!.style.height = `${hPx}px`; gl!.viewport(0, 0, W, H); gl!.uniform1f(uDpr, dpr); // Apply dot size based on container size or manual prop const [minS, maxS] = resolveDotSize(dotSize, wPx); gl!.uniform2f(uDotSize, minS, maxS); } const rect = container.getBoundingClientRect(); resize(rect.width || 36, rect.height || 36); const curW: [number, number, number, number, number] = [...targetWeights]; let raf = 0; let last = performance.now(); let elapsed = 0; function render(now: number) { const dt = Math.min((now - last) * 0.001, 0.05); last = now; elapsed += dt * speed; // Smooth organic morphing between 3D variations const lerpSpd = 5.0 * dt; curW[0] += (targetWeights[0] - curW[0]) * lerpSpd; curW[1] += (targetWeights[1] - curW[1]) * lerpSpd; curW[2] += (targetWeights[2] - curW[2]) * lerpSpd; curW[3] += (targetWeights[3] - curW[3]) * lerpSpd; curW[4] += (targetWeights[4] - curW[4]) * lerpSpd; gl!.clearColor(0, 0, 0, 0); gl!.clear(gl!.COLOR_BUFFER_BIT); gl!.uniform1f(uTime, elapsed); gl!.uniform4f(uW0, curW[0], curW[1], curW[2], curW[3]); gl!.uniform1f(uWBlob, curW[4]); gl!.drawArrays(gl!.POINTS, 0, dotCount); raf = requestAnimationFrame(render); } raf = requestAnimationFrame(render); const ro = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; if (width > 0 && height > 0) { resize(width, height); } } }); ro.observe(container); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [targetWeights, color, highlightColor, dotCount, dotSize, speed, blinkSpeed, tilt]); const modeDescriptions: Record<AIBlobVariation, string> = { "sphere-spiral": "3D Helical Coil Dots", "double-spiral": "3D Double Helix Dots", "rings": "3D Orbital Gyroscope Dots", "torus": "3D Torus Vortex Dots", "blob": "3D Breathing Sphere Dots", }; return ( <div ref={containerRef} onClick={handleToggle} className={cn( "relative inline-flex items-center justify-center shrink-0 rounded-full select-none overflow-hidden", interactive && "cursor-pointer transition-transform duration-200 active:scale-95 hover:scale-110", className, )} style={style} title={interactive ? `Mode: ${modeDescriptions[activeMode]} (Click to switch)` : modeDescriptions[activeMode]} > <canvas ref={canvasRef} className="w-full h-full block pointer-events-none" /> </div> ); } export { AIBlob };
Adds the files to
components/uibrik/ai-blob. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/ai-blob.Props
AIBlob- mode
- Type "sphere-spiral" | "double-spiral" | "rings" | "torus" | "blob"
- "sphere-spiral"
- Active 3D variation.
- interactive
- Type boolean
- true
- Click cycles through the variations.
- dotCount
- Type number
- 50
- Number of dots.
- speed
- Type number
- 1
- Rotation speed multiplier.
- blinkSpeed
- Type number
- 0.3
- Pulse speed of the travelling dots. 0 disables.
- tilt
- Type number
- 18
- 3D tilt pitch in degrees.
- dotSize
- Type number | [number, number]
- –
- Dot size, or a [far, near] depth range.
- color
- Type string
- –
- Base dot colour. Falls back to the text colour.
- highlightColor
- Type string
- –
- Colour of the bright travelling dots.
- onModeChange
- Type (mode) => void
- –
- Called when the variation changes.
- className
- Type string
- –
- Size and text colour, e.g. size-28 text-zinc-700.