{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"displacement-text","type":"registry:ui","title":"DisplacementText","description":"3D text with WebGL displacement that follows the cursor using Three.js shaders","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"dependencies":["three@^0.183.2"],"devDependencies":["@types/three@^0.183.1"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"docs":"Installs three as a dependency.","files":[{"path":"react/src/components/displacement-text/DisplacementText.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport * as THREE from \"three\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { cn } from \"../../utils\";\n\n/**\n * DisplacementText - Large text rendered as a WebGL plane that bulges toward the\n * viewer near the cursor.\n *\n * The text is rasterized to a 2048x2048 canvas and uploaded as a texture; a\n * vertex shader offsets each vertex's `z` by its distance to a `uDisplacement`\n * uniform, which a raycast against an invisible hit-plane updates on every\n * `pointermove`. With reduced motion preferred, without WebGL, or after a lost\n * GPU context, the real text is shown instead, styled like the resting frame.\n */\nexport interface DisplacementTextProps {\n\t/** Text to display */\n\ttext?: string;\n\t/** Font size in pixels */\n\tfontSize?: number;\n\t/** Font family */\n\tfont?: string;\n\t/** Fixed text color (overrides theme colors) */\n\tcolor?: string;\n\t/** Text color in light mode */\n\tlightColor?: string;\n\t/** Text color in dark mode */\n\tdarkColor?: string;\n\t/** Upper bound for the renderer's device pixel ratio */\n\tmaxDpr?: number;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n// The engine below mirrors the Svelte/Vue shared core (displacement-text-core.ts)\n// line for line; keep the two in step.\n\ninterface DisplacementTextElements {\n\t/** Host the renderer canvas is appended to; also the pointer/raycast surface. */\n\tcontainer: HTMLElement;\n}\n\n/** Read once, when the engine is created — changing them later has no effect. */\ninterface DisplacementTextInitOptions {\n\t/**\n\t * Called once if the GPU drops the WebGL context. By then the engine has\n\t * already stopped its loop and released every resource; it stays dead.\n\t */\n\tonContextLost?: () => void;\n}\n\n/**\n * Every key here is live, and none of them rebuilds the scene: the text keys and\n * the colours re-rasterize the texture in place, `color` going from set to unset\n * (or back) only starts/stops the theme observer, and `maxDpr` re-applies the\n * pixel ratio to the existing renderer.\n */\ninterface DisplacementTextLiveOptions {\n\t/** Text to display */\n\ttext?: string;\n\t/** Font size in pixels */\n\tfontSize?: number;\n\t/** Font family */\n\tfont?: string;\n\t/** Fixed text color (overrides theme colors, and disables the dark-mode observer) */\n\tcolor?: string;\n\t/** Text color in light mode */\n\tlightColor?: string;\n\t/** Text color in dark mode */\n\tdarkColor?: string;\n\t/** Upper bound for the renderer's device pixel ratio (default 2). */\n\tmaxDpr?: number;\n}\n\ninterface DisplacementTextEngine {\n\t/** Applies only the keys that changed; never rebuilds the scene. */\n\tsetOptions(next: Partial<DisplacementTextLiveOptions>): void;\n\tresize(): void;\n\tdestroy(): void;\n}\n\n/** Resolved options: every key present, `color` still optional in meaning (undefined = follow the theme). */\ntype ResolvedOptions = {\n\ttext: string;\n\tfontSize: number;\n\tfont: string;\n\tcolor: string | undefined;\n\tlightColor: string;\n\tdarkColor: string;\n\tmaxDpr: number | undefined;\n};\n\nconst DEFAULTS: ResolvedOptions = {\n\ttext: \"Hover Me\",\n\tfontSize: 200,\n\tfont: \"Inter, sans-serif\",\n\tcolor: undefined,\n\tlightColor: \"#000000\",\n\tdarkColor: \"#ffffff\",\n\tmaxDpr: undefined,\n};\n\n/** Default ceiling for the renderer's pixel ratio. */\nconst DEFAULT_MAX_DPR = 2;\n\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\n\n/** Edge of the square canvas the text is rasterized onto, in device px. */\nconst TEXTURE_SIZE = 2048;\n/** Orthographic half-height of the camera frustum. */\nconst CAMERA_DISTANCE = 8;\n/** Visible text plane: 15×15 world units, 100×100 segments so the displacement reads smoothly. */\nconst PLANE_SIZE = 15;\nconst PLANE_SEGMENTS = 100;\n/** Invisible raycast target, big enough that the pointer always hits it. */\nconst HIT_PLANE_SIZE = 500;\n\n/**\n * How the resting frame looks on screen, as a CSS transform. The camera sits at\n * (0, -10, 5) looking at the origin, so the plane is seen tilted: screen x is\n * world x and screen y is world y times 5/√125 (= 1/√5). The plane itself is\n * turned 45° about z, which reads as a counter-clockwise turn on screen.\n */\nconst STATIC_TEXT_TRANSFORM = `scaleY(${1 / Math.sqrt(5)}) rotate(-45deg)`;\n\n/**\n * Static text size, as a share of the layer's height: texture px → world units\n * (PLANE_SIZE / TEXTURE_SIZE) → share of the 2 × CAMERA_DISTANCE tall frustum.\n */\nfunction staticFontSize(fontSize: number): string {\n\treturn `calc(${(fontSize * PLANE_SIZE) / TEXTURE_SIZE / (2 * CAMERA_DISTANCE)} * 100cqh)`;\n}\n\n/** Inline style for the layer that holds the static text (a size container filling the host). */\nconst STATIC_LAYER_STYLE = {\n\tposition: \"absolute\",\n\tinset: \"0\",\n\tdisplay: \"flex\",\n\talignItems: \"center\",\n\tjustifyContent: \"center\",\n\tcontainerType: \"size\",\n\toverflow: \"hidden\",\n} as const;\n\n/** Inline style for the static text: the real string, laid out like the resting frame. */\nfunction staticTextStyle(\n\toptions: { fontSize?: number; font?: string },\n\tcolor: string\n): {\n\tfontFamily: string;\n\tfontSize: string;\n\tfontWeight: string;\n\tcolor: string;\n\tlineHeight: string;\n\twhiteSpace: string;\n\ttransform: string;\n} {\n\treturn {\n\t\tfontFamily: options.font ?? DEFAULTS.font,\n\t\tfontSize: staticFontSize(options.fontSize ?? DEFAULTS.fontSize),\n\t\tfontWeight: \"bold\",\n\t\tcolor,\n\t\tlineHeight: \"1\",\n\t\twhiteSpace: \"nowrap\",\n\t\ttransform: STATIC_TEXT_TRANSFORM,\n\t};\n}\n\n/** `min(deviceRatio, maxDpr)`; a missing device ratio counts as 1, an unusable cap as the default. */\nfunction resolvePixelRatio(deviceRatio: number | undefined, maxDpr: number | undefined): number {\n\tconst cap =\n\t\tmaxDpr !== undefined && Number.isFinite(maxDpr) && maxDpr > 0 ? maxDpr : DEFAULT_MAX_DPR;\n\tconst device = deviceRatio && Number.isFinite(deviceRatio) && deviceRatio > 0 ? deviceRatio : 1;\n\treturn Math.min(device, cap);\n}\n\n/** The colour on screen: the fixed one, else the theme's. Light when there is no document. */\nfunction resolveTextColor(options: {\n\tcolor?: string;\n\tlightColor?: string;\n\tdarkColor?: string;\n}): string {\n\tif (options.color) return options.color;\n\tconst dark =\n\t\ttypeof document !== \"undefined\" && document.documentElement.classList.contains(\"dark\");\n\treturn dark\n\t\t? (options.darkColor ?? DEFAULTS.darkColor)\n\t\t: (options.lightColor ?? DEFAULTS.lightColor);\n}\n\n/**\n * Whether a WebGL2 context (what the renderer needs) can be created here. The\n * probe context is released straight away so it does not count against the\n * browser's limit of live contexts.\n */\nfunction canCreateWebGLContext(): boolean {\n\tif (typeof document === \"undefined\") return false;\n\ttry {\n\t\tconst probe = document.createElement(\"canvas\");\n\t\tconst gl = probe.getContext(\"webgl2\") as WebGL2RenderingContext | null;\n\t\tif (!gl) return false;\n\t\tgl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nconst vertexShader = `\n\tvarying vec2 vUv;\n\tuniform vec3 uDisplacement;\n\n\tfloat easeInOutCubic(float x) {\n\t\treturn x < 0.5 ? 4.0 * x * x * x : 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0;\n\t}\n\n\tfloat map(float value, float min1, float max1, float min2, float max2) {\n\t\treturn min2 + (value - min1) * (max2 - min2) / (max1 - min1);\n\t}\n\n\tvoid main() {\n\t\tvUv = uv;\n\t\tvec3 displaced = position;\n\t\tvec4 worldPosition = modelMatrix * vec4(position, 1.0);\n\t\tfloat dist = length(uDisplacement - worldPosition.rgb);\n\t\tfloat minDistance = 3.0;\n\n\t\tif (dist < minDistance) {\n\t\t\tfloat mapped = map(dist, 0.0, minDistance, 1.0, 0.0);\n\t\t\tdisplaced.z += easeInOutCubic(mapped);\n\t\t}\n\n\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);\n\t}\n`;\n\nconst fragmentShader = `\n\tvarying vec2 vUv;\n\tuniform sampler2D uTexture;\n\n\tvoid main() {\n\t\tgl_FragColor = texture2D(uTexture, vUv);\n\t}\n`;\n\n/** Rasterize `t` centred on a square canvas and upload it as a texture. */\nfunction createTextTexture(t: string, size: number, f: string, c: string): THREE.Texture {\n\tconst canvas = document.createElement(\"canvas\");\n\tcanvas.width = TEXTURE_SIZE;\n\tcanvas.height = TEXTURE_SIZE;\n\tconst ctx = canvas.getContext(\"2d\");\n\tif (!ctx) return new THREE.CanvasTexture(canvas);\n\n\tctx.clearRect(0, 0, canvas.width, canvas.height);\n\tctx.font = `bold ${size}px ${f}`;\n\tctx.fillStyle = c;\n\tctx.textAlign = \"center\";\n\tctx.textBaseline = \"middle\";\n\tctx.fillText(t, canvas.width / 2, canvas.height / 2);\n\n\tconst texture = new THREE.CanvasTexture(canvas);\n\ttexture.needsUpdate = true;\n\treturn texture;\n}\n\nconst LIVE_KEYS = [\n\t\"text\",\n\t\"fontSize\",\n\t\"font\",\n\t\"color\",\n\t\"lightColor\",\n\t\"darkColor\",\n\t\"maxDpr\",\n] as const;\n\n/**\n * Build the three.js scene inside `container` and drive it.\n *\n * A host that reports no size is drawn as a 1x1 box until the next resize.\n * Returns `null` when the renderer cannot get a WebGL context (three throws) or\n * when building the scene throws; in both cases nothing is left appended to the\n * host and nothing stays allocated, the WebGL context included.\n */\nfunction createDisplacementText(\n\telements: DisplacementTextElements,\n\toptions: DisplacementTextInitOptions & DisplacementTextLiveOptions\n): DisplacementTextEngine | null {\n\tconst { container } = elements;\n\tconst { onContextLost } = options;\n\n\tlet opts: ResolvedOptions = { ...DEFAULTS };\n\tfor (const key of LIVE_KEYS) {\n\t\tif (options[key] !== undefined) (opts as Record<string, unknown>)[key] = options[key];\n\t}\n\n\tconst rect = container.getBoundingClientRect();\n\tconst width = rect.width || 1;\n\tconst height = rect.height || 1;\n\n\tlet renderer: THREE.WebGLRenderer;\n\ttry {\n\t\trenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });\n\t} catch {\n\t\treturn null;\n\t}\n\n\t// Everything the scene needs is built before the canvas joins the host, and\n\t// a throw part-way (a shader or texture the platform rejects) releases what\n\t// was already made, so a failed build leaves no canvas and no GPU objects.\n\tconst built: { dispose(): void }[] = [renderer];\n\tlet scene: THREE.Scene;\n\tlet camera: THREE.OrthographicCamera;\n\tlet geometry: THREE.PlaneGeometry;\n\tlet currentColor: string;\n\tlet textTexture: THREE.Texture;\n\tlet shaderMaterial: THREE.ShaderMaterial;\n\tlet hitGeometry: THREE.PlaneGeometry;\n\tlet hitMaterial: THREE.MeshBasicMaterial;\n\tlet hitPlane: THREE.Mesh;\n\ttry {\n\t\tscene = new THREE.Scene();\n\t\tscene.background = null;\n\n\t\tconst aspect = width / height;\n\t\tcamera = new THREE.OrthographicCamera(\n\t\t\t-CAMERA_DISTANCE * aspect,\n\t\t\tCAMERA_DISTANCE * aspect,\n\t\t\tCAMERA_DISTANCE,\n\t\t\t-CAMERA_DISTANCE,\n\t\t\t0.01,\n\t\t\t1000\n\t\t);\n\t\tcamera.position.set(0, -10, 5);\n\t\tcamera.lookAt(0, 0, 0);\n\n\t\trenderer.setClearColor(0x000000, 0);\n\t\trenderer.setPixelRatio(resolvePixelRatio(window.devicePixelRatio, opts.maxDpr));\n\t\trenderer.setSize(width, height, false);\n\t\trenderer.domElement.style.width = \"100%\";\n\t\trenderer.domElement.style.height = \"100%\";\n\n\t\tgeometry = new THREE.PlaneGeometry(PLANE_SIZE, PLANE_SIZE, PLANE_SEGMENTS, PLANE_SEGMENTS);\n\t\tbuilt.push(geometry);\n\n\t\tcurrentColor = resolveTextColor(opts);\n\t\ttextTexture = createTextTexture(opts.text, opts.fontSize, opts.font, currentColor);\n\t\tbuilt.push(textTexture);\n\n\t\tshaderMaterial = new THREE.ShaderMaterial({\n\t\t\tuniforms: {\n\t\t\t\tuTexture: { value: textTexture },\n\t\t\t\tuDisplacement: { value: new THREE.Vector3(0, 0, 0) },\n\t\t\t},\n\t\t\tvertexShader,\n\t\t\tfragmentShader,\n\t\t\ttransparent: true,\n\t\t\tdepthWrite: false,\n\t\t\tside: THREE.DoubleSide,\n\t\t});\n\t\tbuilt.push(shaderMaterial);\n\n\t\tconst plane = new THREE.Mesh(geometry, shaderMaterial);\n\t\tplane.rotation.z = Math.PI / 4;\n\t\tscene.add(plane);\n\n\t\thitGeometry = new THREE.PlaneGeometry(HIT_PLANE_SIZE, HIT_PLANE_SIZE);\n\t\tbuilt.push(hitGeometry);\n\t\thitMaterial = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 });\n\t\tbuilt.push(hitMaterial);\n\t\thitPlane = new THREE.Mesh(hitGeometry, hitMaterial);\n\t\tscene.add(hitPlane);\n\t} catch {\n\t\t// dispose() alone leaves the context alive until garbage collection, and\n\t\t// browsers cap live contexts, so it is dropped explicitly first.\n\t\trenderer.forceContextLoss();\n\t\tfor (const item of built) item.dispose();\n\t\treturn null;\n\t}\n\n\tcontainer.appendChild(renderer.domElement);\n\n\tconst raycaster = new THREE.Raycaster();\n\tconst pointer = new THREE.Vector2();\n\n\t/** Swap in a freshly rasterized texture and release the old one. */\n\tfunction retexture(nextColor: string) {\n\t\tconst tex = createTextTexture(opts.text, opts.fontSize, opts.font, nextColor);\n\t\t// `uTexture` is one of the two uniforms declared just above — always present.\n\t\tshaderMaterial.uniforms.uTexture!.value = tex;\n\t\ttextTexture.dispose();\n\t\ttextTexture = tex;\n\t\tcurrentColor = nextColor;\n\t}\n\n\tconst onPointerMove = (e: PointerEvent) => {\n\t\tconst bounds = container.getBoundingClientRect();\n\t\tpointer.x = ((e.clientX - bounds.left) / bounds.width) * 2 - 1;\n\t\tpointer.y = -((e.clientY - bounds.top) / bounds.height) * 2 + 1;\n\t\traycaster.setFromCamera(pointer, camera);\n\t\tconst [hit] = raycaster.intersectObject(hitPlane);\n\t\t// `uDisplacement` is one of the two uniforms declared just above — always present.\n\t\tif (hit) (shaderMaterial.uniforms.uDisplacement!.value as THREE.Vector3).copy(hit.point);\n\t};\n\n\tcontainer.addEventListener(\"pointermove\", onPointerMove);\n\n\tconst handleResize = () => {\n\t\tconst r = container.getBoundingClientRect();\n\t\tif (r.height === 0) return;\n\t\tconst a = r.width / r.height;\n\t\tcamera.left = -CAMERA_DISTANCE * a;\n\t\tcamera.right = CAMERA_DISTANCE * a;\n\t\tcamera.updateProjectionMatrix();\n\t\trenderer.setSize(r.width, r.height, false);\n\t};\n\n\twindow.addEventListener(\"resize\", handleResize);\n\n\tlet animationId = 0;\n\tconst animate = () => {\n\t\tanimationId = requestAnimationFrame(animate);\n\t\trenderer.render(scene, camera);\n\t};\n\tanimate();\n\n\tconst observer = new MutationObserver(() => {\n\t\tconst next = resolveTextColor(opts);\n\t\tif (next !== currentColor) retexture(next);\n\t});\n\tlet observing = false;\n\tfunction syncObserver() {\n\t\tconst want = !opts.color;\n\t\tif (want === observing) return;\n\t\tobserving = want;\n\t\tif (want)\n\t\t\tobserver.observe(document.documentElement, {\n\t\t\t\tattributes: true,\n\t\t\t\tattributeFilter: [\"class\"],\n\t\t\t});\n\t\telse observer.disconnect();\n\t}\n\tsyncObserver();\n\n\t// A lost context invalidates every GL object, so rather than rebuild in\n\t// place the engine releases everything and hands the decision back to the\n\t// caller. preventDefault() keeps the context eligible for restoration.\n\tconst onContextLostEvent = (event: Event) => {\n\t\tevent.preventDefault();\n\t\trelease(true);\n\t\tonContextLost?.();\n\t};\n\trenderer.domElement.addEventListener(\"webglcontextlost\", onContextLostEvent, false);\n\n\tlet released = false;\n\tfunction release(contextAlreadyLost = false) {\n\t\tif (released) return;\n\t\treleased = true;\n\t\trenderer.domElement.removeEventListener(\"webglcontextlost\", onContextLostEvent);\n\t\twindow.removeEventListener(\"resize\", handleResize);\n\t\tcontainer.removeEventListener(\"pointermove\", onPointerMove);\n\t\tcancelAnimationFrame(animationId);\n\t\tif (observing) observer.disconnect();\n\t\tobserving = false;\n\t\tif (renderer.domElement.parentNode === container) container.removeChild(renderer.domElement);\n\t\t// Without an explicit loss the context outlives dispose() until garbage\n\t\t// collection, so repeated mounts or reduced-motion toggles would pile up\n\t\t// live contexts against the browser's cap.\n\t\tif (!contextAlreadyLost) renderer.forceContextLoss();\n\t\trenderer.dispose();\n\t\ttextTexture.dispose();\n\t\tgeometry.dispose();\n\t\tshaderMaterial.dispose();\n\t\thitGeometry.dispose();\n\t\thitMaterial.dispose();\n\t}\n\n\treturn {\n\t\tsetOptions(next: Partial<DisplacementTextLiveOptions>) {\n\t\t\tif (released) return;\n\t\t\tconst prev = opts;\n\t\t\tconst merged: ResolvedOptions = { ...opts };\n\t\t\tfor (const key of LIVE_KEYS) {\n\t\t\t\tif (key in next) (merged as Record<string, unknown>)[key] = next[key];\n\t\t\t}\n\t\t\t// Defaults stand in for keys passed as undefined, as at creation.\n\t\t\tfor (const key of LIVE_KEYS) {\n\t\t\t\tif (merged[key] === undefined && key !== \"color\" && key !== \"maxDpr\")\n\t\t\t\t\t(merged as Record<string, unknown>)[key] = DEFAULTS[key];\n\t\t\t}\n\t\t\tif (LIVE_KEYS.every((k) => merged[k] === prev[k])) return;\n\t\t\topts = merged;\n\n\t\t\tif (merged.maxDpr !== prev.maxDpr) {\n\t\t\t\trenderer.setPixelRatio(resolvePixelRatio(window.devicePixelRatio, merged.maxDpr));\n\t\t\t\thandleResize();\n\t\t\t}\n\t\t\tsyncObserver();\n\t\t\tconst nextColor = resolveTextColor(merged);\n\t\t\tif (\n\t\t\t\tnextColor !== currentColor ||\n\t\t\t\tmerged.text !== prev.text ||\n\t\t\t\tmerged.fontSize !== prev.fontSize ||\n\t\t\t\tmerged.font !== prev.font\n\t\t\t)\n\t\t\t\tretexture(nextColor);\n\t\t},\n\t\tresize() {\n\t\t\tif (released) return;\n\t\t\thandleResize();\n\t\t},\n\t\tdestroy() {\n\t\t\trelease();\n\t\t},\n\t};\n}\n\ntype DisplacementTextMode = \"static\" | \"webgl\";\n\ninterface DisplacementTextState {\n\t/** `static`: the wrapper shows the real text; `webgl`: the canvas is live. */\n\tmode: DisplacementTextMode;\n\t/** The colour the text resolves to right now (theme-aware unless `color` is fixed). */\n\tcolor: string;\n}\n\ninterface DisplacementTextControllerOptions extends DisplacementTextLiveOptions {\n\t/** Read once at mount: called with every state change (and once with the initial state). */\n\tonChange: (state: DisplacementTextState) => void;\n}\n\ninterface DisplacementTextController {\n\tsetOptions(next: Partial<DisplacementTextLiveOptions>): void;\n\tresize(): void;\n\tdestroy(): void;\n}\n\n/**\n * Owns the engine's lifecycle for a mounted wrapper:\n *\n * - while `prefers-reduced-motion: reduce` matches, no engine exists — no loop,\n *   no GL context — and the state is `static`; a live change of the preference\n *   destroys or creates the engine without remounting the wrapper;\n * - the engine is only created once a WebGL2 context is known to be obtainable,\n *   and a context loss drops to `static` for the rest of the mount;\n * - the resolved text colour is reported (and follows the theme) so the static\n *   text matches what the canvas would draw.\n */\nfunction mountDisplacementText(\n\telements: DisplacementTextElements,\n\toptions: DisplacementTextControllerOptions\n): DisplacementTextController {\n\tconst { onChange } = options;\n\tlet opts: DisplacementTextLiveOptions = {};\n\tfor (const key of LIVE_KEYS) opts[key] = options[key] as never;\n\n\tlet engine: DisplacementTextEngine | null = null;\n\tlet contextLost = false;\n\tlet destroyed = false;\n\tlet state: DisplacementTextState = { mode: \"static\", color: resolveTextColor(opts) };\n\n\tfunction publish(next: DisplacementTextState) {\n\t\tif (next.mode === state.mode && next.color === state.color) return;\n\t\tstate = next;\n\t\tonChange(state);\n\t}\n\n\tconst mql =\n\t\ttypeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n\t\t\t? window.matchMedia(REDUCED_MOTION_QUERY)\n\t\t\t: null;\n\n\tfunction start() {\n\t\tif (engine || contextLost || destroyed || mql?.matches) return;\n\t\tif (!canCreateWebGLContext()) return;\n\t\t// The engine already cleans up after a failed build; the guard keeps any\n\t\t// other throw from escaping into the wrapper's mount hook, so the\n\t\t// controller always exists and `destroy()` always drops its listeners.\n\t\ttry {\n\t\t\tengine = createDisplacementText(elements, {\n\t\t\t\t...opts,\n\t\t\t\tonContextLost() {\n\t\t\t\t\tengine = null;\n\t\t\t\t\tcontextLost = true;\n\t\t\t\t\tpublish({ ...state, mode: \"static\" });\n\t\t\t\t},\n\t\t\t});\n\t\t} catch {\n\t\t\tengine = null;\n\t\t}\n\t\tif (engine) publish({ ...state, mode: \"webgl\" });\n\t}\n\n\tfunction stop() {\n\t\tconst current = engine;\n\t\tengine = null;\n\t\tcurrent?.destroy();\n\t\tpublish({ ...state, mode: \"static\" });\n\t}\n\n\tconst onMotionChange = (event: MediaQueryListEvent) => {\n\t\tif (destroyed) return;\n\t\tif (event.matches) stop();\n\t\telse start();\n\t};\n\tmql?.addEventListener(\"change\", onMotionChange);\n\n\tconst themeObserver =\n\t\ttypeof MutationObserver !== \"undefined\"\n\t\t\t? new MutationObserver(() => publish({ ...state, color: resolveTextColor(opts) }))\n\t\t\t: null;\n\tlet watchingTheme = false;\n\tfunction syncThemeObserver() {\n\t\tconst want = !opts.color && themeObserver !== null;\n\t\tif (want === watchingTheme) return;\n\t\twatchingTheme = want;\n\t\tif (want)\n\t\t\tthemeObserver?.observe(document.documentElement, {\n\t\t\t\tattributes: true,\n\t\t\t\tattributeFilter: [\"class\"],\n\t\t\t});\n\t\telse themeObserver?.disconnect();\n\t}\n\n\tonChange(state);\n\tsyncThemeObserver();\n\tstart();\n\n\treturn {\n\t\tsetOptions(next) {\n\t\t\tif (destroyed) return;\n\t\t\topts = { ...opts, ...next };\n\t\t\tsyncThemeObserver();\n\t\t\tpublish({ ...state, color: resolveTextColor(opts) });\n\t\t\tengine?.setOptions(next);\n\t\t},\n\t\tresize() {\n\t\t\tif (destroyed) return;\n\t\t\tengine?.resize();\n\t\t},\n\t\tdestroy() {\n\t\t\tif (destroyed) return;\n\t\t\tdestroyed = true;\n\t\t\tmql?.removeEventListener(\"change\", onMotionChange);\n\t\t\tif (watchingTheme) themeObserver?.disconnect();\n\t\t\twatchingTheme = false;\n\t\t\tconst current = engine;\n\t\t\tengine = null;\n\t\t\tcurrent?.destroy();\n\t\t},\n\t};\n}\n\nexport function DisplacementText({\n\ttext = \"Hover Me\",\n\tfontSize = 200,\n\tfont = \"Inter, sans-serif\",\n\tcolor,\n\tlightColor = \"#000000\",\n\tdarkColor = \"#ffffff\",\n\tmaxDpr = DEFAULT_MAX_DPR,\n\tclassName = \"\",\n}: DisplacementTextProps) {\n\tconst containerRef = useRef<HTMLDivElement>(null);\n\tconst controllerRef = useRef<DisplacementTextController | null>(null);\n\tconst options = { text, fontSize, font, color, lightColor, darkColor, maxDpr };\n\tconst mountOptions = useLiveRef(options);\n\n\t// Starts \"static\" so the server render (and the first client render) carry\n\t// the real text; the controller promotes to the canvas once motion is\n\t// allowed and WebGL is available, and drops back when reduced motion turns on.\n\tconst [state, setState] = useState<DisplacementTextState>(() => ({\n\t\tmode: \"static\",\n\t\tcolor: color ?? lightColor,\n\t}));\n\n\tuseEffect(() => {\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\t\tconst controller = mountDisplacementText(\n\t\t\t{ container },\n\t\t\t{ ...mountOptions.current, onChange: setState }\n\t\t);\n\t\tcontrollerRef.current = controller;\n\t\treturn () => {\n\t\t\tcontroller.destroy();\n\t\t\tif (controllerRef.current === controller) controllerRef.current = null;\n\t\t};\n\t}, [mountOptions]);\n\n\t// Options are diffed by the engine: nothing here rebuilds the scene.\n\tuseEffect(() => {\n\t\tcontrollerRef.current?.setOptions({\n\t\t\ttext,\n\t\t\tfontSize,\n\t\t\tfont,\n\t\t\tcolor,\n\t\t\tlightColor,\n\t\t\tdarkColor,\n\t\t\tmaxDpr,\n\t\t});\n\t}, [text, fontSize, font, color, lightColor, darkColor, maxDpr]);\n\n\t// The canvas is a decorative rendering of the text, so the container carries\n\t// it for assistive technology; the static fallback is the real string.\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\trole=\"img\"\n\t\t\taria-label={text}\n\t\t\tclassName={cn(\"relative h-[400px] w-full\", className)}\n\t\t>\n\t\t\t{state.mode === \"static\" ? (\n\t\t\t\t<div style={STATIC_LAYER_STYLE}>\n\t\t\t\t\t<span style={staticTextStyle({ fontSize, font }, state.color)}>{text}</span>\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/displacement-text/DisplacementText.tsx"},{"path":"react/src/components/displacement-text/index.ts","content":"\"use client\";\n\nexport { DisplacementText } from \"./DisplacementText\";\nexport type { DisplacementTextProps } from \"./DisplacementText\";\n","type":"registry:ui","target":"@ui/fancy/components/displacement-text/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/displacement-text?framework=react"}}