Generate interactive, animated text experiences powered by AI emotion analysis and Pretext's pixel-perfect text layout engine. Use this skill whenever the user wants to animate text, create kinetic typography, make words come alive, build interactive text art, or produce any visual where text itself is the main creative element. Trigger on phrases like "animate this text", "make text interactive", "kinetic typography", "text animation", "words that move", "living text", "dynamic text", "text art", or any request combining writing/content creation with motion, interactivity, or visual impact. Also trigger when the user wants to visualize a poem, quote, story excerpt, or any short text in a visually striking way. The output is always a single self-contained HTML artifact the user can interact with directly in chat.
Creates self-contained interactive HTML text animations. AI reads the text's emotional tone, maps it to a visual style, and layers mouse interactions — the more the user moves and clicks, the richer the experience.
Uses Pretext (@chenglou/pretext via esm.sh) for pixel-accurate character positioning so every animation unit lands exactly where it should, even on resize. No DOM measurement, no reflow.
Read the input and pick one per row:
| Dimension | Options |
|---|---|
| Emotional register | calm / tense / joyful / melancholic / urgent / mysterious |
| Rhythm | slow drift / sharp staccato / wave / pulse / scatter |
| Color mood | warm (amber/coral) / cool (teal/blue) / monochrome / high-contrast |
| Archetype | see Step 2 |
Write choices as // Mood / Archetype / Interactions: at top of <script>.
Pick one (or combine two for multi-part text):
| Archetype | Best for | Core idea |
|---|---|---|
| Magnetic | any text, max interactivity | chars repelled by cursor, snap back via physics |
| Gravity Words | headlines, quotes | words fall from above, bounce into place |
| Scatter & Gather | short phrases | click explodes chars, click again reforms |
| Liquid Flow | lyrics, passages | words ripple away from cursor softly |
| Orbit | single strong word | letters orbit center; cursor warps gravity |
| Shatter | dramatic/tense | click shatters chars into triangle fragments |
| Breath | poems, meditative | chars expand/contract in wave rhythm |
| Typewriter+ | story openings | types in char by char, then words are clickable |
Tone → style quick map:
| Tone | Archetype | Font | Palette |
|---|---|---|---|
| Joyful | Scatter & Gather | Pacifico | Amber + Coral |
| Melancholic | Breath | Playfair Display | Cool gray + Teal |
| Tense | Shatter + Magnetic | Bebas Neue | Red + near-black |
| Calm | Liquid Flow | Cormorant Garamond | Soft blue-gray |
| Mysterious | Orbit | Cinzel | Deep purple + gold |
| Bold | Gravity Words | Instrument Serif | High contrast B&W |
| Playful | Magnetic | Righteous | Multi-color |
These are the 7 interaction layers. All code is below — copy and combine.
// Run inside physics loop every frame
function applyProximity(u) {
const dx = u.x - mx, dy = u.y - my;
const dist = Math.hypot(dx, dy) || 1;
if (dist < repelRadius) {
const str = (1 - dist / repelRadius) * repelForce;
u.vx += (dx / dist) * str;
u.vy += (dy / dist) * str;
}
}
// mousemove handler
stage.addEventListener('mousemove', e => {
const r = stage.getBoundingClientRect();
mx = e.clientX - r.left; my = e.clientY - r.top;
dot.style.left = mx + 'px'; dot.style.top = my + 'px';
trail.push({ x: mx, y: my });
if (trail.length > TRAIL_MAX) trail.shift();
});
stage.addEventListener('mouseleave', () => { mx = -9999; my = -9999; trail = []; });
const TRAIL_MAX = 16;
let trail = [];
// Pre-create ghost elements
const ghosts = Array.from({ length: TRAIL_MAX }, () => {
const g = document.createElement('span');
g.style.cssText = `position:absolute;pointer-events:none;font:${FONT};color:${ACCENT};opacity:0;transform:translate(-50%,-50%);`;
container.appendChild(g);
return g;
});
// Call inside rAF loop
function renderTrail() {
trail.forEach((pt, i) => {
const g = ghosts[i];
if (!g) return;
const alpha = (i / trail.length) * 0.4;
const sc = 0.3 + (i / trail.length) * 0.7;
// nearest char to trail point
let near = units[0];
units.forEach(u => { if (Math.hypot(u.origHomeX-pt.x, u.origHomeY-pt.y) < Math.hypot(near.origHomeX-pt.x, near.origHomeY-pt.y)) near = u; });
g.textContent = near?.el.textContent || '·';
g.style.left = pt.x + 'px'; g.style.top = pt.y + 'px';
g.style.opacity = String(alpha);
g.style.transform = `translate(-50%,-50%) scale(${sc})`;
});
for (let i = trail.length; i < ghosts.length; i++) ghosts[i].style.opacity = '0';
}
stage.addEventListener('click', e => {
const r = stage.getBoundingClientRect();
const cx = e.clientX - r.left, cy = e.clientY - r.top;
const blastR = 200, blastF = 150;
units.forEach(u => {
const dx = u.x - cx, dy = u.y - cy;
const dist = Math.hypot(dx, dy) || 1;
if (dist < blastR) {
const power = (1 - dist / blastR) * blastF;
u.vx += (dx / dist) * power;
u.vy += (dy / dist) * power;
}
});
// Visual ring pulse
const ring = document.createElement('div');
ring.style.cssText = `position:absolute;border-radius:50%;border:1.5px solid ${ACCENT};
left:${cx}px;top:${cy}px;width:0;height:0;transform:translate(-50%,-50%);
pointer-events:none;transition:width .5s ease-out,height .5s ease-out,opacity .5s;opacity:.8;`;
stage.appendChild(ring);
requestAnimationFrame(() => { ring.style.width = blastR*2+'px'; ring.style.height = blastR*2+'px'; ring.style.opacity='0'; });
setTimeout(() => ring.remove(), 550);
});
// Attach to each word/char element after building
function attachHover(u) {
u.el.addEventListener('mouseenter', () => {
u.el.style.color = ACCENT;
u.el.style.textShadow = `0 0 20px ${ACCENT}88`;
});
u.el.addEventListener('mouseleave', () => {
u.el.style.color = BASE_COLOR;
u.el.style.textShadow = '';
});
u.el.addEventListener('click', e => {
e.stopPropagation();
if (typeof sendPrompt === 'function')
sendPrompt(`Tell me more about the word "${u.el.textContent.trim()}"`);
});
}
// Each unit: { el, homeX, homeY, origHomeX, origHomeY, x, y, vx, vy }
let springK = 0.07, damping = 0.82, speedMult = 1;
function physicsLoop() {
units.forEach(u => {
if (u === dragging) return;
applyProximity(u); // Layer 1
u.vx += (u.homeX - u.x) * springK * speedMult;
u.vy += (u.homeY - u.y) * springK * speedMult;
u.vx *= damping; u.vy *= damping;
u.x += u.vx * speedMult; u.y += u.vy * speedMult;
u.el.style.transform = `translate(${u.x - u.homeX}px,${u.y - u.homeY}px)`;
});
easeTilt(); // Layer 7
renderTrail(); // Layer 2
requestAnimationFrame(physicsLoop);
}
physicsLoop();
let dragging = null, prevMx = 0, prevMy = 0, throwVx = 0, throwVy = 0;
// On mousedown, find nearest unit within grab radius
stage.addEventListener('mousedown', e => {
const r = stage.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
let best = null, bestD = 32;
units.forEach(u => { const d = Math.hypot(u.x-px, u.y-py); if (d < bestD) { bestD=d; best=u; } });
if (best) {
dragging = best; best.el.style.zIndex = '50';
prevMx = px; prevMy = py; throwVx = 0; throwVy = 0;
}
});
stage.addEventListener('mousemove', e => {
if (!dragging) return;
const r = stage.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
throwVx = px - prevMx; throwVy = py - prevMy;
dragging.x = px; dragging.y = py;
dragging.el.style.transform = `translate(${px-dragging.homeX}px,${py-dragging.homeY}px)`;
prevMx = px; prevMy = py;
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging.vx = throwVx * 2; dragging.vy = throwVy * 2;
dragging.el.style.zIndex = ''; dragging = null;
});
let tiltX = 0, tiltY = 0;
stage.addEventListener('wheel', e => {
e.preventDefault();
tiltY = Math.max(-22, Math.min(22, tiltY + e.deltaY * 0.05));
tiltX = Math.max(-22, Math.min(22, tiltX - e.deltaX * 0.05));
container.style.transform = `perspective(700px) rotateX(${tiltY}deg) rotateY(${tiltX}deg)`;
}, { passive: false });
function easeTilt() {
tiltX *= 0.94; tiltY *= 0.94;
if (Math.abs(tiltX) > 0.05 || Math.abs(tiltY) > 0.05)
container.style.transform = `perspective(700px) rotateX(${tiltY}deg) rotateY(${tiltX}deg)`;
}
Import (always use this CDN — no npm, works in any browser artifact):
import { prepare, layoutWithLines } from 'https://esm.sh/@chenglou/pretext@latest';
Full build pattern — char-by-char with physics units:
async function build() {
await document.fonts.ready; // wait for Google Font to load
container.innerHTML = ''; units = [];
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = FONT;
const prepared = prepare(TEXT, FONT);
const W = stage.offsetWidth - 60;
const { lines } = layoutWithLines(prepared, W, LINE_H);
const totalH = lines.length * LINE_H;
const startY = (stage.offsetHeight - totalH) / 2;
lines.forEach((line, li) => {
const lw = ctx.measureText(line.text).width;
let x = (stage.offsetWidth - lw) / 2; // centered
const y = startY + li * LINE_H;
[...line.text].forEach(ch => {
const w = ctx.measureText(ch).width;
if (ch !== ' ') {
const el = document.createElement('span');
el.textContent = ch;
el.style.cssText = `position:absolute;left:${x}px;top:${y}px;font:${FONT};color:${BASE_COLOR};will-change:transform;cursor:grab;`;
container.appendChild(el);
const u = { el, homeX:x, homeY:y, origHomeX:x, origHomeY:y, x, y, vx:0, vy:0 };
attachHover(u); // Layer 4
units.push(u);
}
x += w;
});
});
}
Resize — smooth reflow via physics (don't hard-rebuild, just update home positions):
let rsTimer;
window.addEventListener('resize', () => {
clearTimeout(rsTimer);
rsTimer = setTimeout(async () => {
// Re-compute positions only
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = FONT;
const { lines } = layoutWithLines(prepare(TEXT, FONT), stage.offsetWidth - 60, LINE_H);
const totalH = lines.length * LINE_H;
const startY = (stage.offsetHeight - totalH) / 2;
let i = 0;
lines.forEach((line, li) => {
const lw = ctx.measureText(line.text).width;
let x = (stage.offsetWidth - lw) / 2;
const y = startY + li * LINE_H;
[...line.text].forEach(ch => {
const w = ctx.measureText(ch).width;
if (ch !== ' ' && units[i]) {
units[i].homeX = units[i].origHomeX = x;
units[i].homeY = units[i].origHomeY = y;
i++;
}
x += w;
});
});
// Physics loop springs chars to new positions automatically
}, 180);
});
CJK / multilingual:
import { prepare, setLocale, layoutWithLines } from 'https://esm.sh/@chenglou/pretext@latest';
setLocale('zh'); // call before prepare()
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=CHOSEN_FONT&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
#stage {
width:100%; min-height:400px; background:BG_COLOR;
position:relative; overflow:hidden; cursor:none; user-select:none;
}
#container { position:absolute; inset:0; transform-style:preserve-3d; }
.ch { position:absolute; will-change:transform; }
#cursor-dot {
position:absolute; width:8px; height:8px; border-radius:50%;
background:ACCENT; transform:translate(-50%,-50%); pointer-events:none; z-index:30;
}
#controls {
display:flex; gap:10px; padding:9px 16px; background:DARKER_BG;
align-items:center; flex-wrap:wrap;
border-top:0.5px solid rgba(255,255,255,.08);
}
#controls label { font-family:monospace; font-size:10px; color:rgba(255,255,255,.4); display:flex; align-items:center; gap:7px; }
#controls button {
font-family:monospace; font-size:10px; background:transparent;
border:0.5px solid rgba(255,255,255,.2); color:rgba(255,255,255,.5);
padding:5px 13px; cursor:pointer; border-radius:2px;
}
#controls button:hover { border-color:ACCENT; color:ACCENT; }
input[type=range] { accent-color:ACCENT; width:72px; }
</style>
<div id="stage" aria-label="[describe the animation]">
<div id="container"></div>
<div id="cursor-dot"></div>
</div>
<div id="controls">
<label>Repel <input type="range" id="repelSlider" min="10" max="120" value="60"></label>
<label>Speed <input type="range" id="speedSlider" min="0.3" max="2.5" step="0.1" value="1"></label>
<button onclick="scatterAll()">Scatter ✦</button>
<button onclick="gatherAll()">Gather ◎</button>
<button onclick="replayDrop()">Replay ↺</button>
</div>
<script type="module">
// Mood: [X] / Archetype: [X] / Interactions: layers 1+2+3+4+5+6+7
import { prepare, layoutWithLines } from 'https://esm.sh/@chenglou/pretext@latest';
const TEXT = `[USER TEXT HERE]`;
const FONT = `[weight] [size] '[Font Name]'`;
const LINE_H = 70;
const ACCENT = '#ACCENT_COLOR';
const BASE_COLOR = '#BASE_COLOR';
const stage = document.getElementById('stage');
const container = document.getElementById('container');
const dot = document.getElementById('cursor-dot');
let units = [], mx = -9999, my = -9999, trail = [];
let repelRadius = 60, repelForce = 60, speedMult = 1;
let dragging = null, prevMx = 0, prevMy = 0, throwVx = 0, throwVy = 0;
let tiltX = 0, tiltY = 0;
// [paste all 7 layer functions here]
// [paste build() here]
// [paste resize handler here]
// [paste controls listeners here]
window.scatterAll = () => {
const W = stage.offsetWidth, H = stage.offsetHeight;
units.forEach(u => { u.homeX = 40 + Math.random()*(W-80); u.homeY = 40 + Math.random()*(H-80); });
};
window.gatherAll = () => units.forEach(u => { u.homeX = u.origHomeX; u.homeY = u.origHomeY; });
window.replayDrop = () => {
gatherAll();
units.forEach((u, i) => {
u.y = u.homeY - 150; u.vx = 0; u.vy = 0;
u.el.style.opacity = '0';
setTimeout(() => { u.el.style.transition='opacity .3s'; u.el.style.opacity='1'; u.vy = 10; setTimeout(()=>u.el.style.transition='',350); }, i * 55);
});
};
document.getElementById('repelSlider').oninput = e => repelRadius = repelForce = +e.target.value;
document.getElementById('speedSlider').oninput = e => speedMult = +e.target.value;
build();
</script>
requestAnimationFrame loophttps://esm.sh/@chenglou/pretext@latestawait document.fonts.ready before prepare()cursor: none on #stage, custom dot elementhomeX/homeY, physics springs to new positionsaria-label on #stage