类型:3D · 分类:Text Animation · 收录:2026-09-16 20:35:01 · 源码:会员可见
付费可阅读内容 · 可复制
# Semantic Bloom
A customizable Codex wordmark that draws a viscous particle organism toward its letters, illuminating the text as the network searches and reconnects.
> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/semantic-bloom · 预览动画:https://threeui.com/previews/semantic-bloom.webm
> 分类:Text Animation
/* ===== src/shaders/semantic-bloom/SemanticBloom.tsx (tsx) ===== */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import semanticExplorerSource from "./sources/design-f0ebbe02-7d8a-41fd-9041-a1124185c27b.html?raw";
export type SemanticBloomProps = {
text?: string;
mode?: "dark" | "light";
size?: number;
opacity?: number;
className?: string;
style?: CSSProperties;
};
export const SEMANTIC_BLOOM_DEFAULTS = {
text: "Codex",
mode: "dark" as const,
size: 1,
opacity: 1,
};
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
function buildFocusedDocument(mode: "dark" | "light") {
const light = mode === "light";
const surface = light ? "#f4f4f2" : "#030303";
const restingText = light ? "#989894" : "#555555";
const activeText = light ? "#151515" : "#e0e0e0";
const particle = light ? "rgba(28, 28, 28, 0.78)" : "rgba(200, 200, 200, 0.8)";
const connection = light ? "rgba(48, 48, 46, 0.14)" : "rgba(120, 120, 120, 0.15)";
const focusStyles = `<style data-semantic-bloom-focus>
:root {
--bg-color: ${surface};
--text-color: ${restingText};
--highlight-color: ${activeText};
--sf-semantic-size: 1;
}
html, body { background: ${surface}; }
.query-container, .status-hud { display: none !important; }
.interface-layer {
display: flex;
align-items: center;
justify-content: center;
padding: clamp(2rem, 8vw, 8rem);
}
.journal-area {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: auto;
overflow: visible;
color: ${restingText};
font-size: clamp(4rem, calc(12vw * var(--sf-semantic-size)), 11rem);
font-weight: 300;
line-height: 1;
letter-spacing: -0.065em;
text-align: center;
white-space: pre-wrap;
caret-color: transparent;
}
.journal-area .word { display: inline-block; color: ${restingText}; }
#cursor-follower { display: none; border-color: ${light ? "rgba(0,0,0,0.28)" : "rgba(255,255,255,0.3)"}; }
@media (max-width: 640px) {
.interface-layer { padding: 1.5rem; }
.journal-area { font-size: clamp(3rem, calc(19vw * var(--sf-semantic-size)), 7rem); }
}
</style>`;
const controls = `<script data-semantic-bloom-controls>
(function () {
window.__SEMANTIC_BLOOM_PAUSED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.__SEMANTIC_BLOOM_FRAME_PENDING = false;
function setText(value) {
var next = typeof value === 'string' && value.trim() ? value.slice(0, 72) : 'Codex';
editor.textContent = next;
editor.dataset.processed = 'false';
editor.setAttribute('contenteditable', 'false');
scanText();
}
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'semantic-bloom-controls') return;
var next = event.data.controls || {};
if (typeof next.text === 'string') setText(next.text);
if (Number.isFinite(next.size)) {
document.documentElement.style.setProperty('--sf-semantic-size', String(Math.max(0.55, Math.min(1.6, next.size))));
updateWordCoords();
}
var wasPaused = window.__SEMANTIC_BLOOM_PAUSED;
window.__SEMANTIC_BLOOM_PAUSED = Boolean(next.paused);
if (wasPaused && !window.__SEMANTIC_BLOOM_PAUSED && !window.__SEMANTIC_BLOOM_FRAME_PENDING && typeof animate === 'function') animate();
});
})();
</script>`;
return semanticExplorerSource
.replace("<title vid="4">Organic Semantic Explorer</title>", "<title vid="4">Semantic Bloom</title>")
.replace("</head>", `${focusStyles}</head>`)
.replace("ctx.fillStyle = 'rgba(200, 200, 200, 0.8)';", `ctx.fillStyle = '${particle}';`)
.replace("ctx.strokeStyle = 'rgba(120, 120, 120, 0.15)';", `ctx.strokeStyle = '${connection}';`)
.replace(
"function animate() {n n ctx.clearRect(0, 0, width, height);",
"function animate() {n window.__SEMANTIC_BLOOM_FRAME_PENDING = false;n ctx.clearRect(0, 0, width, height);",
)
.replace(
"requestAnimationFrame(animate);",
"if (!window.__SEMANTIC_BLOOM_PAUSED) { window.__SEMANTIC_BLOOM_FRAME_PENDING = true; requestAnimationFrame(animate); }",
)
.replace("</body>", `${controls}</body>`);
}
export function SemanticBloom({
text = SEMANTIC_BLOOM_DEFAULTS.text,
mode = SEMANTIC_BLOOM_DEFAULTS.mode,
size = SEMANTIC_BLOOM_DEFAULTS.size,
opacity = SEMANTIC_BLOOM_DEFAULTS.opacity,
className = "",
style,
}: SemanticBloomProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [hostVisible, setHostVisible] = useState(true);
const [documentVisible, setDocumentVisible] = useState(() => typeof document === "undefined" || !document.hidden);
const safeMode = mode === "light" ? "light" : "dark";
const source = useMemo(() => buildFocusedDocument(safeMode), [safeMode]);
const paused = !hostVisible || !documentVisible;
const postControls = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage({
type: "semantic-bloom-controls",
controls: {
text: text.trim() || SEMANTIC_BLOOM_DEFAULTS.text,
size: clamp(size, 0.55, 1.6),
paused,
},
}, "*");
}, [paused, size, text]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || typeof IntersectionObserver === "undefined") return undefined;
const observer = new IntersectionObserver(([entry]) => setHostVisible(entry?.isIntersecting ?? true));
observer.observe(iframe);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (typeof document === "undefined") return undefined;
const update = () => setDocumentVisible(!document.hidden);
document.addEventListener("visibilitychange", update);
return () => document.removeEventListener("visibilitychange", update);
}, []);
useEffect(() => {
postControls();
}, [postControls, source]);
return (
<div
className={`threeui-background semantic-bloom semantic-bloom--${safeMode}${className ? ` ${className}` : ""}`}
data-mode={safeMode}
style={{ background: safeMode === "light" ? "#f4f4f2" : "#030303", pointerEvents: "auto", ...style }}
>
<iframe
ref={iframeRef}
title={`Semantic Bloom: ${text.trim() || SEMANTIC_BLOOM_DEFAULTS.text}`}
srcDoc={source}
sandbox="allow-scripts"
onLoad={postControls}
style={{
position: "absolute",
inset: 0,
display: "block",
width: "100%",
height: "100%",
border: 0,
background: "transparent",
opacity: clamp(opacity, 0.1, 1),
}}
/>
</div>
);
}
/* ===== src/shaders/semantic-bloom/sources/design-f0ebbe02-7d8a-41fd-9041-a1124185c27b.html (html) ===== */
<html lang="en" vid="0"><head vid="1">
<meta charset="UTF-8" vid="2">
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
<title vid="4">Organic Semantic Explorer</title>
<style vid="5">
:root {
--bg-color: #030303;
--text-color: #6a6a6a;
--highlight-color: #e0e0e0;
--organism-core: #ffffff;
--organism-outer: #808080;
--font-main: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-mono: 'Courier Prime', 'Courier New', monospace;
}
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-main);
overflow: hidden;
cursor: none;
}
#bio-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
filter: url('#liquid-filter') contrast(150%) brightness(100%);
opacity: 0.9;
pointer-events: none;
}
.noise-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 3;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.08'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
.interface-layer {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
padding: 4rem 15%;
box-sizing: border-box;
}
.query-container {
margin-bottom: 4rem;
position: relative;
}
.query-label {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #444;
margin-bottom: 1rem;
display: block;
}
.query-input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid #222;
color: var(--highlight-color);
font-size: 1.5rem;
font-weight: 300;
padding: 0.5rem 0;
outline: none;
font-family: var(--font-main);
transition: border-color 0.5s ease;
}
.query-input:focus {
border-bottom: 1px solid #555;
}
.journal-area {
position: relative;
width: 100%;
height: 100%;
font-size: 1.5rem;
line-height: 1.8;
font-weight: 300;
color: #333;
white-space: pre-wrap;
outline: none;
border: none;
resize: none;
background: transparent;
overflow-y: auto;
scrollbar-width: none;
}
.journal-area::-webkit-scrollbar {
display: none;
}
.word {
position: relative;
transition: color 1s ease, text-shadow 0.5s ease;
cursor: text;
z-index: 2;
}
.word.active {
color: var(--highlight-color);
text-shadow: 0 0 10px rgba(255,255,255,0.2);
}
.word.connected {
color: #999;
}
#cursor-follower {
position: fixed;
top: 0;
left: 0;
width: 20px;
height: 20px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 50%;
pointer-events: none;
z-index: 10;
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
mix-blend-mode: exclusion;
}
.status-hud {
position: fixed;
bottom: 2rem;
right: 2rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: #333;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
pointer-events: none;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
background-color: #333;
border-radius: 50%;
margin-left: 8px;
animation: pulse 4s infinite;
}
@keyframes pulse {
0% { opacity: 0.2; }
50% { opacity: 1; }
100% { opacity: 0.2; }
}
</style>
</head>
<body vid="6">
<svg style="position: absolute; width: 0; height: 0;" vid="7">
<defs vid="8">
<filter id="liquid-filter" vid="9">
<feGaussianBlur in="SourceGraphic" stdDeviation="12" result="blur" vid="10"></feGaussianBlur>
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" result="goo" vid="11"></feColorMatrix>
<feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves="3" result="noise" vid="12"></feTurbulence>
<feDisplacementMap in="goo" in2="noise" scale="20" xChannelSelector="R" yChannelSelector="G" vid="13"></feDisplacementMap>
<feComposite in="SourceGraphic" operator="atop" vid="14"></feComposite>
</filter>
</defs>
</svg>
<div class="noise-overlay" vid="15"></div>
<canvas id="bio-layer" vid="16"></canvas>
<div class="interface-layer" vid="17">
<div class="query-container" vid="18">
<span class="query-label" vid="19">Active Organism // Query</span>
<input type="text" class="query-input" value="Why do patterns repeat in isolation?" placeholder="Enter a theme to explore..." vid="20">
</div>
<div class="journal-area" id="editor" contenteditable="true" spellcheck="false" vid="21">
The silence in this room feels heavy, not empty. I have been thinking about the structure of fungal networks, how they don't just grow; they search. They send out filaments into the dark, gambling energy on the possibility of sustenance.
It feels like my own thoughts are doing the same. Branching out. Retracting.
Yesterday I felt a disconnect between my intent and my action. A gap. The koji mold doesn't have gaps; it is a continuous stream of becoming. If I could map my anxiety like a mycelial network, would it look chaotic? Or would it reveal a geometry I am too close to see?
We speak of "finding meaning" as if it is a hidden object, but perhaps meaning is just the friction of movement. The residue of the search itself.
I am trying to be still. To let the organism settle.
</div>
</div>
<div class="status-hud" vid="22">
<span vid="23">SUBSTRATE ANALYSIS: ACTIVE</span>
<span vid="24">SEMANTIC DENSITY: 0.84 <span class="status-dot" vid="25"></span></span>
</div>
<div id="cursor-follower" vid="26"></div>
<script vid="27">
const canvas = document.getElementById('bio-layer');
const ctx = canvas.getContext('2d');
const editor = document.getElementById('editor');
const cursorFollower = document.getElementById('cursor-follower');
let width, height;
let mouse = { x: -1000, y: -1000 };
let lastMouse = { x: -1000, y: -1000 };
const config = {
particleCount: 50,
baseRadius: 15,
viscosity: 0.08,
wanderStrength: 0.5,
connectionDist: 150,
repelDist: 50,
textAttractionRadius: 200
};
let particles = [];
let wordCoordinates = [];
function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
scanText();
}
window.addEventListener('resize', resize);
function scanText() {
const text = editor.innerText;
if (editor.dataset.processed === "true") {
updateWordCoords();
return;
}
const words = text.split(/(s+)/);
editor.innerHTML = '';
words.forEach(word => {
const span = document.createElement('span');
span.textContent = word;
if (word.trim().length > 0) {
span.className = 'word';
}
editor.appendChild(span);
});
editor.dataset.processed = "true";
updateWordCoords();
}
function updateWordCoords() {
wordCoordinates = [];
const spans = document.querySelectorAll('.word');
spans.forEach(span => {
const rect = span.getBoundingClientRect();
wordCoordinates.push({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
w: rect.width,
element: span,
active: false
});
});
}
class Particle {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.radius = config.baseRadius * (0.8 + Math.random() * 0.5);
this.targetX = x;
this.targetY = y;
this.angle = Math.random() * Math.PI * 2;
}
update() {
this.angle += (Math.random() - 0.5) * 0.2;
this.vx += Math.cos(this.angle) * config.wanderStrength;
this.vy += Math.sin(this.angle) * config.wanderStrength;
this.vx *= 0.95;
this.vy *= 0.95;
const dx = mouse.x - this.x;
const dy = mouse.y - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 400) {
this.vx += dx * 0.002;
this.vy += dy * 0.002;
}
let closestDist = Infinity;
let closestWord = null;
wordCoordinates.forEach(word => {
const wdx = word.x - this.x;
const wdy = word.y - this.y;
const wDist = Math.sqrt(wdx * wdx + wdy * wdy);
if (wDist < closestDist) {
closestDist = wDist;
closestWord = word;
}
});
if (closestWord && closestDist < config.textAttractionRadius) {
const wdx = closestWord.x - this.x;
const wdy = closestWord.y - this.y;
this.vx += wdx * 0.01;
this.vy += wdy * 0.01;
if (closestDist < 30) {
if (!closestWord.active) {
closestWord.element.classList.add('active');
closestWord.active = true;
}
} else if (closestDist > 60) {
if (closestWord.active) {
closestWord.element.classList.remove('active');
closestWord.active = false;
}
}
}
this.x += this.vx;
this.y += this.vy;
if (this.x < 0) this.x = width;
if (this.x > width) this.x = 0;
if (this.y < 0) this.y = height;
if (this.y > height) this.y = 0;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(200, 200, 200, 0.8)';
ctx.fill();
}
}
function init() {
resize();
for (let i = 0; i < config.particleCount; i++) {
particles.push(new Particle(width/2 + (Math.random()-0.5)*100, height/2 + (Math.random()-0.5)*100));
}
scanText();
}
function animate() {
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(120, 120, 120, 0.15)';
ctx.lineWidth = 4;
for (let i = 0; i < particles.length; i++) {
let p1 = particles[i];
p1.update();
p1.draw();
for (let j = i + 1; j < particles.length; j++) {
let p2 = particles[j];
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < config.connectionDist) {
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}
}
requestAnimationFrame(animate);
}
window.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
cursorFollower.style.transform = `translate(${e.clientX}px, ${e.clientY}px) scale(1)`;
});
document.querySelector('.query-input').addEventListener('input', () => {
particles.forEach(p => {
p.vx += (Math.random() - 0.5) * 5;
p.vy += (Math.random() - 0.5) * 5;
});
});
editor.addEventListener('input', () => {
});
init();
animate();
setTimeout(scanText, 100);
</script>
</body></html>
