Curious AIA morphing 3D AI orb in Three.js with cyan and magenta rim lights, iridescent pink speckles on a wrinkled surface, and glowing pink robot-eye pills that follow your cursor and blink between idle looks.
'use client'
// npm install three framer-motion
import { useEffect, useRef, useState } from 'react'
import { motion, useMotionValue, useSpring } from 'framer-motion'
import * as THREE from 'three'
// Inline theme reader — observes `.dark` on `<html>` and an optional
// `[data-card-theme]` ancestor (per-card override on the homepage grid).
// Kept inline so this file is self-contained when copy-pasted.
function useScopedTheme(ref: React.RefObject<HTMLElement | null>) {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
const element = ref.current
if (!element) return
const readTheme = () => {
const scope = element.closest('[data-card-theme]') as HTMLElement | null
if (scope) {
setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light')
return
}
setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light')
}
readTheme()
const observers: MutationObserver[] = []
let current: HTMLElement | null = element
while (current) {
const obs = new MutationObserver(readTheme)
obs.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] })
observers.push(obs)
current = current.parentElement
}
const htmlObs = new MutationObserver(readTheme)
htmlObs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
observers.push(htmlObs)
return () => observers.forEach((o) => o.disconnect())
}, [ref])
return theme
}
// ─── Shaders ──────────────────────────────────────────────────────────────────
const VERT = /* glsl */ `
uniform float uTime;
uniform float uActive; // 0..1 lerp toward "alert"
uniform vec2 uLook; // -1..1, current look direction (cursor or idle script)
uniform float uReduce; // 1 = motion allowed, 0 = motion frozen
varying vec3 vNormal;
varying vec3 vViewDir;
varying vec3 vLocalPos; // original mesh position — for stable surface speckles
varying vec3 vViewPos; // view-space position — fragment derives the displaced normal from this
// Classic 3D simplex noise — Ashima Arts / Stefan Gustavson, MIT.
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;}
vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
float snoise(vec3 v){
const vec2 C=vec2(1./6.,1./3.);
const vec4 D=vec4(0.,.5,1.,2.);
vec3 i=floor(v+dot(v,C.yyy));
vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);
vec3 l=1.-g;
vec3 i1=min(g.xyz,l.zxy);
vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;
vec3 x2=x0-i2+C.yyy;
vec3 x3=x0-D.yyy;
i=mod289(i);
vec4 p=permute(permute(permute(
i.z+vec4(0.,i1.z,i2.z,1.))
+ i.y+vec4(0.,i1.y,i2.y,1.))
+ i.x+vec4(0.,i1.x,i2.x,1.));
float n_=1./7.;
vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z);
vec4 y_=floor(j-7.*x_);
vec4 x=x_*ns.x+ns.yyyy;
vec4 y=y_*ns.x+ns.yyyy;
vec4 h=1.-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy);
vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.+1.;
vec4 s1=floor(b1)*2.+1.;
vec4 sh=-step(h,vec4(0.));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x);
vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z);
vec3 p3=vec3(a1.zw,h.w);
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m=m*m;
return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
void main(){
vLocalPos = position; // stable surface coord — speckles stick here
vec3 pos = position;
float t = uTime * uReduce;
// Slow anisotropic stretch — orb morphs through ovoid orientations over time.
float ax = sin(t * 0.27 + 0.0) * 0.16;
float ay = sin(t * 0.19 + 1.7) * 0.20;
float az = sin(t * 0.23 + 3.1) * 0.12;
pos *= vec3(1.0 + ax, 1.0 + ay, 1.0 + az);
// Two octaves — keep the surface glossier/smoother for the new aesthetic.
float nLow = snoise(pos * 0.55 + vec3( t * 0.18, t * 0.13, -t * 0.15));
float nMid = snoise(pos * 1.45 + vec3(-t * 0.22, t * 0.20, t * 0.18));
// Look direction biases the noise sample — the "face" bulges where it looks.
vec3 lookDir = vec3(uLook, 0.55);
float facing = clamp(dot(normalize(pos), normalize(lookDir)), 0.0, 1.0);
float bulge = pow(facing, 2.5) * (0.08 + uActive * 0.10);
// Gentle breath — overall radial pulse, very slow.
float breath = sin(t * 0.55) * 0.022;
float amp = mix(0.20, 0.34, uActive);
float displacement = (nLow * 0.66 + nMid * 0.34) * amp + bulge + breath;
pos += normal * displacement;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
vNormal = normalize(normalMatrix * normal);
vViewDir = normalize(-mv.xyz);
vViewPos = mv.xyz;
gl_Position = projectionMatrix * mv;
}
`
const FRAG = /* glsl */ `
precision highp float;
uniform vec3 uBase;
uniform vec3 uRimA; // cyan rim (upper-left light)
uniform vec3 uRimB; // magenta rim (lower-right light)
uniform vec3 uSpeckA; // electric pink speckle
uniform vec3 uSpeckB; // electric cyan speckle
uniform float uActive;
varying vec3 vNormal;
varying vec3 vViewDir;
varying vec3 vLocalPos;
varying vec3 vViewPos;
// Simplex 3D noise — same as vertex, needed for speckle sampling.
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;}
vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
float snoise(vec3 v){
const vec2 C=vec2(1./6.,1./3.);
const vec4 D=vec4(0.,.5,1.,2.);
vec3 i=floor(v+dot(v,C.yyy));
vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);
vec3 l=1.-g;
vec3 i1=min(g.xyz,l.zxy);
vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;
vec3 x2=x0-i2+C.yyy;
vec3 x3=x0-D.yyy;
i=mod289(i);
vec4 p=permute(permute(permute(
i.z+vec4(0.,i1.z,i2.z,1.))
+ i.y+vec4(0.,i1.y,i2.y,1.))
+ i.x+vec4(0.,i1.x,i2.x,1.));
float n_=1./7.;
vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z);
vec4 y_=floor(j-7.*x_);
vec4 x=x_*ns.x+ns.yyyy;
vec4 y=y_*ns.x+ns.yyyy;
vec4 h=1.-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy);
vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.+1.;
vec4 s1=floor(b1)*2.+1.;
vec4 sh=-step(h,vec4(0.));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x);
vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z);
vec3 p3=vec3(a1.zw,h.w);
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m=m*m;
return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
void main(){
// True surface normal derived from view-space position derivatives — picks
// up the actual wrinkles/ridges from the displaced geometry instead of the
// smooth icosahedron normal. Gives proper shadow-in-valley, highlight-on-
// ridge shading.
vec3 dx = dFdx(vViewPos);
vec3 dy = dFdy(vViewPos);
vec3 n = normalize(cross(dx, dy));
vec3 v = normalize(-vViewPos);
// Fresnel — bright at glancing angles, dark where the surface faces us.
float fres = 1.0 - clamp(dot(n, v), 0.0, 1.0);
// ── Diffuse 3D lighting on the body ───────────────────────────────────────
// Two soft lights: warm-cyan key from upper-left-front, cool-magenta fill
// from lower-right. Combined with derivative normal, this makes wrinkle
// ridges catch light and valleys fall into shadow.
vec3 keyDir = normalize(vec3(-0.45, 0.70, 0.85));
vec3 fillDir = normalize(vec3( 0.65, -0.35, 0.55));
float diffKey = max(0.0, dot(n, keyDir));
float diffFill = max(0.0, dot(n, fillDir));
// Ambient + key + fill — wrap the body in directional light so ridges and
// valleys actually read as 3D.
vec3 lit = uBase * (0.30 + diffKey * 0.95 + diffFill * 0.45);
// ── Two-tone rim lights ──────────────────────────────────────────────────
vec3 dirCyan = normalize(vec3(-0.70, 0.55, 0.50));
vec3 dirMagenta = normalize(vec3( 0.75, -0.30, 0.50));
float cyanWrap = max(0.0, dot(n, dirCyan));
float magentaWrap = max(0.0, dot(n, dirMagenta));
float rimCoreP = mix(2.2, 1.7, uActive);
float rimCyan = pow(cyanWrap, 1.3) * pow(fres, rimCoreP);
float rimMagenta = pow(magentaWrap, 1.3) * pow(fres, rimCoreP);
// ── Specular highlight on ridges ─────────────────────────────────────────
// Blinn-half-vector style spec from the key light — puts a moving glint on
// ridges that face the light, sells the glossy / wet quality.
vec3 halfKey = normalize(keyDir + v);
float specKey = pow(max(0.0, dot(n, halfKey)), 32.0) * 0.55;
vec3 col = lit
+ uRimA * rimCyan * mix(1.10, 1.55, uActive)
+ uRimB * rimMagenta * mix(1.00, 1.45, uActive)
+ specKey * vec3(0.80, 0.95, 1.00);
// ── Iridescent speckles (sparser than before) ────────────────────────────
// Higher thresholds → ~half the previous density. Single big-scale layer
// with a small-scale accent.
float speckBig = snoise(vLocalPos * 10.0);
float speckSmall = snoise(vLocalPos * 24.0 + 1.7);
float maskBig = smoothstep(0.66, 0.74, speckBig);
float maskSmall = smoothstep(0.72, 0.78, speckSmall) * 0.40;
float speckMask = max(maskBig, maskSmall);
// Per-cluster colour pick — biased toward pink, with cyan accents.
float colorPick = snoise(vLocalPos * 4.0 + 5.3);
vec3 speckColor = mix(uSpeckA, uSpeckB, smoothstep(0.55, 0.75, colorPick));
// Fade speckles in valley shadows so they read as surface, not stickers.
float speckBody = 1.0 - smoothstep(0.55, 0.95, fres);
col += speckColor * speckMask * speckBody * 0.85;
gl_FragColor = vec4(col, 1.0);
}
`
// ─── Theme palette ────────────────────────────────────────────────────────────
// Dark teal body with two-tone rim (cyan / magenta) and iridescent pink+cyan
// speckles — same on both themes since the orb is intrinsically dark.
type Palette = {
base: [number, number, number]
rimA: [number, number, number] // cyan rim — upper-left light
rimB: [number, number, number] // magenta rim — lower-right light
speckA: [number, number, number] // electric pink speckle
speckB: [number, number, number] // electric cyan speckle
eye: string
eyeGlow: string
}
const PALETTE: Record<'dark' | 'light', Palette> = {
dark: {
base: [0.050, 0.075, 0.085],
rimA: [0.380, 0.860, 0.940],
rimB: [0.730, 0.330, 0.940],
speckA: [0.880, 0.275, 0.985],
speckB: [0.400, 0.910, 1.000],
eye: 'rgba(255, 140, 245, 0.85)', // hot pink — slightly translucent so it glows rather than reads solid
eyeGlow: 'rgba(225, 90, 230, 0.70)', // saturated magenta halo
},
light: {
base: [0.075, 0.105, 0.120],
rimA: [0.400, 0.870, 0.940],
rimB: [0.745, 0.355, 0.940],
speckA: [0.895, 0.290, 0.985],
speckB: [0.420, 0.915, 1.000],
eye: 'rgba(255, 150, 248, 0.88)',
eyeGlow: 'rgba(225, 100, 230, 0.75)',
},
}
// ─── Idle look-around script ─────────────────────────────────────────────────
// left → slightly right → slightly left → top → back, then loop.
// x and y are in normalised stage coords (-1..1). y is "up = negative".
const LOOK_SEQUENCE: { x: number; y: number; dur: number }[] = [
{ x: -0.65, y: 0.00, dur: 2400 }, // left
{ x: 0.32, y: 0.00, dur: 2800 }, // slightly right
{ x: -0.24, y: 0.00, dur: 2200 }, // slightly left
{ x: 0.00, y: -0.55, dur: 2800 }, // top
{ x: 0.00, y: 0.00, dur: 2600 }, // back
]
// ─── Component ────────────────────────────────────────────────────────────────
export default function CuriousAi() {
const containerRef = useRef<HTMLDivElement>(null) // outer chrome — theme scope
const stageRef = useRef<HTMLDivElement>(null) // inner square — pointer events + rect
const canvasRef = useRef<HTMLDivElement>(null) // canvas host
const sizeRef = useRef({ w: 320, h: 320 })
// One source of truth for "where is the orb looking?":
// pointer overrides during hover, idle script drives otherwise.
const lookTargetRef = useRef({ x: 0, y: 0 }) // desired
const lookCurrentRef = useRef({ x: 0, y: 0 }) // smoothed
const hoverActiveRef = useRef(false)
// Alertness — how "awake" the orb is. Driven by hover/touch.
const activeRef = useRef(0)
const targetRef = useRef(0)
// Eye HTML offset — driven from RAF loop using lookCurrentRef.
const eyeX = useMotionValue(0)
const eyeY = useMotionValue(0)
const sx = useSpring(eyeX, { stiffness: 200, damping: 22, mass: 0.4 })
const sy = useSpring(eyeY, { stiffness: 200, damping: 22, mass: 0.4 })
const [blinkAt, setBlinkAt] = useState(0)
// Eye open-state: idle (off-page) = 0.85, in container = 0.70, on orb = 0.32 (shrink).
const [open, setOpen] = useState(0.85)
const theme = useScopedTheme(containerRef)
// ── Three.js scene ──────────────────────────────────────────────────────────
useEffect(() => {
const host = canvasRef.current
if (!host) return
const W = host.clientWidth || 320
const H = host.clientHeight || 320
sizeRef.current = { w: W, h: H }
const palette = PALETTE[theme]
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(38, W / H, 0.1, 100)
camera.position.z = 4.4
// Transparent canvas — the orb breathes against whatever page bg is behind.
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(W, H)
renderer.setClearColor(0x000000, 0)
host.appendChild(renderer.domElement)
const detail = W < 400 ? 32 : 64
const geo = new THREE.IcosahedronGeometry(1, detail)
const uniforms: Record<string, THREE.IUniform> = {
uTime: { value: 0 },
uActive: { value: 0 },
uLook: { value: new THREE.Vector2(0, 0) },
uReduce: { value: 1 },
uBase: { value: new THREE.Color(...palette.base) },
uRimA: { value: new THREE.Color(...palette.rimA) },
uRimB: { value: new THREE.Color(...palette.rimB) },
uSpeckA: { value: new THREE.Color(...palette.speckA) },
uSpeckB: { value: new THREE.Color(...palette.speckB) },
}
const mat = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
transparent: true,
})
const mesh = new THREE.Mesh(geo, mat)
scene.add(mesh)
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
const applyMotion = () => { uniforms.uReduce.value = mql.matches ? 0 : 1 }
applyMotion()
mql.addEventListener('change', applyMotion)
const ro = new ResizeObserver((entries) => {
const r = entries[0]?.contentRect
if (!r) return
const nw = Math.max(1, Math.floor(r.width))
const nh = Math.max(1, Math.floor(r.height))
sizeRef.current = { w: nw, h: nh }
renderer.setSize(nw, nh)
camera.aspect = nw / nh
camera.updateProjectionMatrix()
})
ro.observe(host)
let raf = 0
const clock = new THREE.Clock()
function tick() {
raf = requestAnimationFrame(tick)
const dt = Math.min(clock.getDelta(), 0.05)
uniforms.uTime.value += dt
// Alertness smoothing.
const ka = 1 - Math.exp(-dt * 6)
activeRef.current += (targetRef.current - activeRef.current) * ka
uniforms.uActive.value = activeRef.current
// Look direction smoothing — slower during idle script (deliberate gaze),
// snappier under cursor (responsive feel).
const speed = hoverActiveRef.current ? 7 : 2.2
const kl = 1 - Math.exp(-dt * speed)
const lc = lookCurrentRef.current
const lt = lookTargetRef.current
lc.x += (lt.x - lc.x) * kl
lc.y += (lt.y - lc.y) * kl
uniforms.uLook.value.set(lc.x, -lc.y)
// Orb leans toward the cursor — visible but still subtler than the eye
// travel, so the eyes remain the primary expressive surface.
const lean = 0.12 + activeRef.current * 0.06
mesh.position.x += (lc.x * lean - mesh.position.x) * kl
mesh.position.y += (-lc.y * lean - mesh.position.y) * kl
// Eye overlay follows the look direction — wide range so it reads clearly
// as "the eyes moved left / right / down". This must out-pace the orb's
// own lean so the eyes carry the personality.
const range = sizeRef.current.w * 0.18
eyeX.set(lc.x * range)
eyeY.set(lc.y * range)
mesh.rotation.y += dt * 0.04
mesh.rotation.x += dt * 0.015
renderer.render(scene, camera)
}
tick()
return () => {
cancelAnimationFrame(raf)
mql.removeEventListener('change', applyMotion)
ro.disconnect()
geo.dispose()
mat.dispose()
renderer.dispose()
if (host.contains(renderer.domElement)) host.removeChild(renderer.domElement)
}
}, [theme, eyeX, eyeY])
// ── Pointer / activity handling ────────────────────────────────────────────
// Events live on the OUTER container so the eyes drift toward the cursor
// anywhere on the page — not just when it's directly on the orb. We
// distinguish three states based on the cursor's distance from the orb's
// centre:
// • off-page → idle script drives look + relaxed eyes (0.85)
// • in container → eyes track slightly toward cursor (0.70)
// • cursor on the orb→ eyes squint/focus + full tracking (0.32)
useEffect(() => {
const container = containerRef.current
const stage = stageRef.current
if (!container || !stage) return
function update(clientX: number, clientY: number) {
const rect = stage!.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const radius = rect.width / 2 // stage is square
const dx = clientX - centerX
const dy = clientY - centerY
const nx = Math.max(-1, Math.min(1, dx / radius))
const ny = Math.max(-1, Math.min(1, dy / radius))
const dist = Math.sqrt(nx * nx + ny * ny)
// The orb visually occupies ~0.65 of the stage half-radius (accounts
// for morph extension beyond the geometric 1.0 mesh radius).
const onOrb = dist < 0.62
hoverActiveRef.current = true
if (onOrb) {
targetRef.current = 1
lookTargetRef.current = { x: nx, y: ny }
setOpen(0.32)
} else {
targetRef.current = 0.35
// Slight tracking — eyes drift toward the cursor but the orb itself
// doesn't lean much.
lookTargetRef.current = { x: nx * 0.40, y: ny * 0.40 }
setOpen(0.70)
}
}
function onMove(e: PointerEvent) {
update(e.clientX, e.clientY)
}
function onEnter(e: PointerEvent) {
update(e.clientX, e.clientY)
setBlinkAt((v) => v + 1)
}
function onLeave() {
hoverActiveRef.current = false
targetRef.current = 0
setOpen(0.85)
setBlinkAt((v) => v + 1)
}
function onDown(e: PointerEvent) {
update(e.clientX, e.clientY)
}
function onUp() {
// Touch lift: small grace period in case it was a tap.
window.setTimeout(() => {
if (!container?.matches(':hover')) onLeave()
}, 500)
}
container.addEventListener('pointermove', onMove)
container.addEventListener('pointerenter', onEnter)
container.addEventListener('pointerleave', onLeave)
container.addEventListener('pointerdown', onDown)
container.addEventListener('pointerup', onUp)
container.addEventListener('pointercancel', onLeave)
return () => {
container.removeEventListener('pointermove', onMove)
container.removeEventListener('pointerenter', onEnter)
container.removeEventListener('pointerleave', onLeave)
container.removeEventListener('pointerdown', onDown)
container.removeEventListener('pointerup', onUp)
container.removeEventListener('pointercancel', onLeave)
}
}, [])
// ── Idle look-around script: left, slightly right, slightly left, top, back ─
useEffect(() => {
if (typeof window === 'undefined') return
let idx = 0
let timer: number
function step() {
const s = LOOK_SEQUENCE[idx]
if (!hoverActiveRef.current) {
lookTargetRef.current = { x: s.x, y: s.y }
}
idx = (idx + 1) % LOOK_SEQUENCE.length
timer = window.setTimeout(step, s.dur)
}
step()
return () => window.clearTimeout(timer)
}, [])
// ── Idle blink scheduler ───────────────────────────────────────────────────
useEffect(() => {
if (typeof window === 'undefined') return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
let t: number
function schedule() {
const wait = 3800 + Math.random() * 3200
t = window.setTimeout(() => {
setBlinkAt((v) => v + 1)
schedule()
}, wait)
}
schedule()
return () => window.clearTimeout(t)
}, [])
// ── Render ─────────────────────────────────────────────────────────────────
const palette = PALETTE[theme]
const bgColor = theme === 'dark' ? '#0A0A09' : '#E8E8DF'
return (
<div
ref={containerRef}
className="relative flex min-h-screen w-full items-center justify-center"
style={{ backgroundColor: bgColor }}
>
<div
ref={stageRef}
className="relative aspect-square h-full max-h-[min(42vh,42vw)] w-full max-w-[min(42vh,42vw)] cursor-pointer select-none touch-none"
>
<div ref={canvasRef} className="absolute inset-0" />
{/* Vertical robot-eye pills — sit on the orb's "face" and track the look direction. */}
<motion.div
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-between"
style={{ x: sx, y: sy, width: '11%', height: '15%' }}
>
<Eye open={open} blinkKey={blinkAt} palette={palette} />
<Eye open={open} blinkKey={blinkAt} palette={palette} />
</motion.div>
</div>
</div>
)
}
// ─── Eye ──────────────────────────────────────────────────────────────────────
// Vertical pill — narrow + tall, like a robot's lens slit. ScaleY animates the
// blink and the open/close transition between idle ↔ alert.
function Eye({
open, blinkKey, palette,
}: {
open: number
blinkKey: number
palette: Palette
}) {
return (
<motion.div
style={{
width: '32%', // ~3.5% of stage — narrow vertical bar
height: '100%', // full wrapper height — ~15% of stage
background: palette.eye,
borderRadius: 9999,
// Two-layer halo: tight inner core glow + wide outer bloom — sells the
// LED-lens read so the eyes feel luminous, not painted on.
boxShadow: `0 0 4px 0 ${palette.eye}, 0 0 14px 1px ${palette.eyeGlow}, 0 0 28px 4px ${palette.eyeGlow}`,
originY: 0.5,
}}
animate={{ scaleY: open }}
transition={{ type: 'spring', stiffness: 240, damping: 22, mass: 0.4 }}
>
<motion.div
key={blinkKey}
className="h-full w-full"
style={{ background: palette.eye, borderRadius: 9999 }}
initial={{ scaleY: 1 }}
animate={{ scaleY: [1, 0.05, 1] }}
transition={{ duration: 0.18, times: [0, 0.45, 1], ease: 'easeInOut' }}
/>
</motion.div>
)
}
Add to your project
One command adds this component to your project.
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/curious-aiFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Curious AI
Curious AI is a morphing 3D AI orb built in Three.js with cyan and magenta rim lights, iridescent pink speckles drifting across a wrinkled displacement surface, and a pair of glowing pink robot-eye pills that track your cursor and blink between idle looks. Motion handles the cursor-follow on the eyes and the idle behaviors, while Three.js owns the orb morph and lighting. It is the canonical character mascot for an AI product hero, marketing-site feature card, or onboarding moment.


