*
*
* Runtime API: element.configure({ ... }).restart()
*/
(() => {
const DEFAULTS = {
word: 'DRIP',
backgroundColor: '#11130f',
backgroundTexture: 'waves',
colors: ['#c8f04a', '#f4f0e8', '#93b52f', '#6f8a22'],
multi: 'on',
shape: 'teardrop',
motion: 'gravity',
font: '900 240px Arial Black, Arial, sans-serif',
density: 1200,
speed: 1,
seed: 42,
gravity: 1,
drift: 0.8,
viscosity: 0.55,
turbulence: 0.35,
interaction: 'ripple',
preset: 'custom',
dropSize: 4.2,
mergeBlur: 1.45,
fit: 1,
tracking: 0.04,
blur: 0,
glow: 0
};
const PRESETS = {
honey: { colors: ['#d89b24', '#ffe08a', '#a96312', '#6b390d'], motion: 'gravity', shape: 'teardrop', gravity: 0.78, drift: 0.35, viscosity: 0.28 },
water: { colors: ['#bcecff', '#ffffff', '#64b6d9', '#266b91'], motion: 'wave', shape: 'round', gravity: 0.22, drift: 0.9, viscosity: 0.18 },
slime: { colors: ['#c8f04a', '#eaff9a', '#76a91d', '#355b0b'], motion: 'blob', shape: 'bead', gravity: 0.42, drift: 0.55, viscosity: 0.68 },
wax: { colors: ['#ff806e', '#ffd1a9', '#d94d5c', '#7d2435'], motion: 'gravity', shape: 'puddle', gravity: 0.9, drift: 0.25, viscosity: 0.72 },
ink: { colors: ['#111111', '#f4f0e8', '#606060', '#202020'], motion: 'swirl', shape: 'ring', gravity: 0.55, drift: 0.8, viscosity: 0.2 },
gel: { colors: ['#d9a8ff', '#fff0ff', '#9c62d4', '#50307c'], motion: 'float', shape: 'bubble', gravity: 0.3, drift: 0.65, viscosity: 0.5 }
};
const OBSERVED = [
'word', 'background', 'background-color', 'texture', 'background-texture',
'colors', 'multi', 'shape', 'motion', 'density', 'speed', 'seed', 'font',
'gravity', 'drift', 'viscosity', 'turbulence', 'interaction', 'preset',
'drop-size', 'merge-blur', 'fit', 'tracking', 'blur', 'glow'
];
class DrippingTextWidget extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
`;
this.canvas = this.shadowRoot.querySelector('canvas');
this.ctx = this.canvas.getContext('2d');
this.layer = document.createElement('canvas');
this.mask = document.createElement('canvas');
this.particles = [];
this.bursts = [];
this.pointer = { x: -9999, y: -9999, down: false };
this.scrollEnergy = 0;
this.config = { ...DEFAULTS, colors: [...DEFAULTS.colors] };
this.random = Math.random;
this.progress = 0;
this.time = 0;
this.last = 0;
this.running = false;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.onPointerMove = e => {
const r = this.canvas.getBoundingClientRect();
this.pointer.x = e.clientX - r.left;
this.pointer.y = e.clientY - r.top;
};
this.onPointerLeave = () => { this.pointer.x = -9999; this.pointer.y = -9999; this.pointer.down = false; };
this.onPointerDown = e => {
this.pointer.down = true;
const r = this.canvas.getBoundingClientRect();
this.bursts.push({ x: e.clientX - r.left, y: e.clientY - r.top, life: 1, radius: 80 });
this.restart();
};
this.onWheel = e => {
this.scrollEnergy = this.clamp(this.scrollEnergy + Math.abs(e.deltaY) / 500, 0, 1);
if (this.config.motion === 'scroll') {
this.progress = this.clamp(this.progress + e.deltaY / 2200, 0, 1);
}
};
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('wheel', this.onWheel, { passive: true });
}
connectedCallback() {
this.applyAttributes();
this.resizeObserver.observe(this);
this.resize();
this.running = true;
requestAnimationFrame(t => this.frame(t));
}
disconnectedCallback() {
this.running = false;
this.resizeObserver.disconnect();
this.canvas.removeEventListener('pointermove', this.onPointerMove);
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
this.canvas.removeEventListener('wheel', this.onWheel);
}
static get observedAttributes() { return OBSERVED; }
attributeChangedCallback() { if (this.isConnected) { this.applyAttributes(); this.resize(); } }
applyAttributes() {
const get = (name) => this.getAttribute(name);
const next = {};
const word = get('word');
if (word != null) next.word = word;
const bg = get('background-color') ?? get('background');
if (bg != null) next.backgroundColor = bg;
const tex = get('background-texture') ?? get('texture');
if (tex != null) next.backgroundTexture = tex;
const colors = get('colors');
if (colors != null) next.colors = colors.split(',').map(s => s.trim()).filter(Boolean);
for (const k of ['shape', 'motion', 'font', 'interaction', 'preset', 'multi']) {
const v = get(k);
if (v != null) next[k] = v;
}
for (const k of ['density', 'speed', 'seed', 'gravity', 'drift', 'viscosity', 'turbulence', 'fit', 'tracking', 'blur', 'glow']) {
const v = get(k);
if (v != null && v !== '') next[k] = Number(v);
}
const dropSize = get('drop-size') ?? get('dropsize');
if (dropSize != null && dropSize !== '') next.dropSize = Number(dropSize);
const mergeBlur = get('merge-blur') ?? get('mergeblur');
if (mergeBlur != null && mergeBlur !== '') next.mergeBlur = Number(mergeBlur);
this.configure(next);
}
configure(next = {}) {
if (next.preset && PRESETS[next.preset]) {
Object.assign(this.config, PRESETS[next.preset]);
this.config.colors = [...PRESETS[next.preset].colors];
this.config.preset = next.preset;
}
if (next.background != null && next.backgroundColor == null) next.backgroundColor = next.background;
if (next.texture != null && next.backgroundTexture == null) next.backgroundTexture = next.texture;
Object.assign(this.config, next);
if (next.colors) this.config.colors = [...next.colors];
if (typeof this.config.word === 'string') {
this.config.word = this.config.word.trim().toUpperCase() || DEFAULTS.word;
}
this.progress = 0;
this.reseed();
if (this.isConnected) this.resize();
return this;
}
restart() {
this.progress = 0;
this.last = 0;
this.reseed();
this.buildParticles();
return this;
}
reseed() { let n = (Number(this.config.seed) || 42) >>> 0; this.random = () => { n = (n * 1664525 + 1013904223) >>> 0; return n / 4294967296; }; }
rand(a, b) { return a + this.random() * (b - a); }
clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
lerp(a, b, t) { return a + (b - a) * t; }
ease(t) { return t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
resize() {
const rect = this.getBoundingClientRect();
this.W = Math.max(1, Math.floor(rect.width || 640));
this.H = Math.max(1, Math.floor(rect.height || 420));
this.dpr = Math.min(window.devicePixelRatio || 1, 1.5);
for (const c of [this.canvas, this.layer, this.mask]) {
c.width = this.W * this.dpr;
c.height = this.H * this.dpr;
}
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
const parsed = Number((String(this.config.font || '').match(/(\d+)px/) || [])[1]);
const fit = Number(this.config.fit);
const box = this.W * .78 * (Number.isFinite(fit) && fit > 0 ? fit : 1);
this.size = this.clamp(Math.min(Number.isFinite(parsed) ? parsed : 240, this.H * .46), 80, 360);
const m = this.mask.getContext('2d');
m.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
m.clearRect(0, 0, this.W, this.H);
m.fillStyle = '#fff';
let font = String(this.config.font || DEFAULTS.font).replace(/\d+px/, `${this.size}px`);
m.font = font;
const track = Number(this.config.tracking);
if (Number.isFinite(track) && m.letterSpacing !== undefined) m.letterSpacing = (track * this.size) + 'px';
if (m.measureText(this.config.word).width > box) {
this.size *= box / m.measureText(this.config.word).width;
font = String(this.config.font || DEFAULTS.font).replace(/\d+px/, `${this.size}px`);
m.font = font;
if (Number.isFinite(track) && m.letterSpacing !== undefined) m.letterSpacing = (track * this.size) + 'px';
}
m.textAlign = 'center';
m.textBaseline = 'middle';
m.fillText(this.config.word, this.W / 2, this.H * .61);
this.reseed();
this.buildParticles();
}
buildParticles() {
if (!this.mask || !this.W) return;
const m = this.mask.getContext('2d');
const data = m.getImageData(0, 0, this.mask.width, this.mask.height).data;
const points = [];
const step = this.clamp(Math.round(this.size / 64), 4, 5);
for (let y = this.H * .43; y < this.H * .79; y += step) {
for (let x = this.W * .14; x < this.W * .86; x += step) {
if (data[(Math.floor(y * this.dpr) * this.mask.width + Math.floor(x * this.dpr)) * 4 + 3] > 20) {
points.push({ x: x + this.rand(-1.3, 1.3), y: y + this.rand(-1.3, 1.3) });
}
}
}
this.particles = [];
const count = Math.min(Number(this.config.density) || 1200, points.length);
for (let i = 0; i < count; i++) {
const p = points[Math.floor(i * points.length / count)];
this.particles.push({
tx: p.x, ty: p.y,
sx: this.rand(this.W * .22, this.W * .78), sy: this.rand(18, 54),
size: this.rand(3.2, 5.2) * ((Number(this.config.dropSize) || 4.2) / 4.2),
delay: this.rand(.02, .54), duration: this.rand(.20, .29),
wobble: this.rand(0, Math.PI * 2),
phase: this.rand(0, 1)
});
}
}
drawBackground() {
const { ctx, W, H } = this;
ctx.fillStyle = this.config.backgroundColor;
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.globalAlpha = .14;
ctx.strokeStyle = this.config.colors[1] || '#fff';
ctx.fillStyle = this.config.colors[1] || '#fff';
const texture = this.config.backgroundTexture;
if (texture === 'scanlines') for (let y = 0; y < H; y += 5) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y + Math.sin(y * .03 + this.time) * 2); ctx.stroke(); }
if (texture === 'grid') {
for (let x = 0; x < W; x += 32) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 32) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
}
if (texture === 'dots') for (let y = 14; y < H; y += 24) for (let x = 14; x < W; x += 24) ctx.fillRect(x, y, 1.5, 1.5);
if (texture === 'waves') for (let y = 0; y < H; y += 18) {
ctx.beginPath();
for (let x = 0; x <= W; x += 14) {
const yy = y + Math.sin(x * .018 + this.time * 1.4 + y * .04) * 5;
x ? ctx.lineTo(x, yy) : ctx.moveTo(x, yy);
}
ctx.stroke();
}
if (texture === 'noise') {
const amount = Math.max(1, Math.floor(W * H / 240));
for (let i = 0; i < amount; i++) ctx.fillRect(Math.random() * W, Math.random() * H, 1, 1);
}
if (texture === 'paper') {
ctx.globalAlpha = .08;
for (let y = 0; y < H; y += 3) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y + Math.sin(y * .13) * 1.5); ctx.stroke(); }
}
ctx.restore();
}
drawShape(g, x, y, rx, ry, shape, rotation) {
g.save();
g.translate(x, y);
g.rotate(rotation || 0);
g.beginPath();
if (shape === 'round') g.ellipse(0, 0, rx, rx, 0, 0, Math.PI * 2);
else if (shape === 'oval') g.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
else if (shape === 'diamond') { g.moveTo(0, -ry); g.lineTo(rx, 0); g.lineTo(0, ry); g.lineTo(-rx, 0); g.closePath(); }
else if (shape === 'triangle') { g.moveTo(0, -ry); g.lineTo(rx, ry * .82); g.lineTo(-rx, ry * .82); g.closePath(); }
else if (shape === 'hexagon') {
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + i / 6 * Math.PI * 2;
const px = Math.cos(a) * rx, py = Math.sin(a) * ry;
i ? g.lineTo(px, py) : g.moveTo(px, py);
}
g.closePath();
}
else if (shape === 'square') g.rect(-rx, -ry, rx * 2, ry * 2);
else if (shape === 'star') {
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + i / 10 * Math.PI * 2;
const r = i % 2 ? rx * .45 : rx * 1.2;
const px = Math.cos(a) * r, py = Math.sin(a) * r;
i ? g.lineTo(px, py) : g.moveTo(px, py);
}
g.closePath();
}
else if (shape === 'ring' || shape === 'bubble') {
g.arc(0, 0, rx, 0, Math.PI * 2);
if (shape === 'ring') g.arc(0, 0, rx * .48, 0, Math.PI * 2, true);
}
else if (shape === 'snowflake') {
g.lineWidth = Math.max(1, rx * .22);
g.lineCap = 'round';
g.strokeStyle = g.fillStyle;
for (let i = 0; i < 6; i++) {
const a = i / 6 * Math.PI;
const px = Math.cos(a) * rx, py = Math.sin(a) * ry;
g.moveTo(-px, -py); g.lineTo(px, py);
g.moveTo(px * .55, py * .55);
g.lineTo(px * .8 + Math.cos(a + .55) * rx * .22, py * .8 + Math.sin(a + .55) * ry * .22);
g.moveTo(px * .55, py * .55);
g.lineTo(px * .8 + Math.cos(a - .55) * rx * .22, py * .8 + Math.sin(a - .55) * ry * .22);
}
g.stroke();
}
else if (shape === 'callout') {
if (g.roundRect) g.roundRect(-rx, -ry * .72, rx * 2, ry * 1.45, rx * .24);
else g.rect(-rx, -ry * .72, rx * 2, ry * 1.45);
g.moveTo(-rx * .35, ry * .7); g.lineTo(-rx * .6, ry * 1.25); g.lineTo(rx * .08, ry * .7); g.closePath();
}
else if (shape === 'dollar' || shape === 'pound' || shape === 'euro') {
g.font = `900 ${ry * 2.05}px Arial, sans-serif`;
g.textAlign = 'center';
g.textBaseline = 'middle';
g.fillText(shape === 'dollar' ? '$' : shape === 'pound' ? '£' : '€', 0, 0);
}
else if (shape === 'bead') {
g.ellipse(0, ry * .18, rx * .86, ry * .78, 0, 0, Math.PI * 2);
g.ellipse(-rx * .25, -ry * .25, rx * .22, ry * .18, 0, 0, Math.PI * 2);
}
else if (shape === 'icicle') {
g.moveTo(-rx, -ry * .25);
g.quadraticCurveTo(0, -ry * .1, rx, -ry * .25);
g.lineTo(rx * .55, ry * .7); g.lineTo(0, ry * 1.5); g.lineTo(-rx * .55, ry * .7);
g.closePath();
}
else if (shape === 'puddle') g.ellipse(0, ry * .25, rx * 1.35, ry * .65, 0, 0, Math.PI * 2);
else if (shape === 'splash') {
for (let i = 0; i < 14; i++) {
const a = i / 14 * Math.PI * 2;
const r = i % 2 ? rx * .72 : rx * 1.34;
const px = Math.cos(a) * r, py = Math.sin(a) * r;
i ? g.lineTo(px, py) : g.moveTo(px, py);
}
g.closePath();
} else {
g.moveTo(0, -ry * 1.45);
g.bezierCurveTo(rx * 1.1, -ry * .35, rx * .95, ry * .65, 0, ry);
g.bezierCurveTo(-rx * .95, ry * .65, -rx * 1.1, -ry * .35, 0, -ry * 1.45);
g.closePath();
}
g.fill();
g.restore();
}
positionOf(p) {
const s = this.config;
const local = this.clamp((this.progress - p.delay) / p.duration, 0, 1);
const t = this.ease(local);
if (local <= 0) return { x: p.sx, y: p.sy, local };
if (local < 1) {
const arc = Math.sin(local * Math.PI) * (p.tx - p.sx) * -.08;
let x = this.lerp(p.sx, p.tx, t) + arc * Number(s.drift || 0);
let y = this.lerp(p.sy, p.ty, t);
const phase = this.time * (1.2 + Number(s.turbulence || 0)) + p.wobble;
const g = Number(s.gravity || 1);
const d = Number(s.drift || 0);
const turb = Number(s.turbulence || 0);
if (s.motion === 'wave') { x += Math.sin(phase + p.ty * .02) * 22 * d; y += Math.cos(phase * .7) * 7; }
if (s.motion === 'swirl') { x += Math.cos(phase) * 18 * (1 - local); y += Math.sin(phase) * 12 * (1 - local); }
if (s.motion === 'float') { x += Math.sin(phase) * 14; y += Math.cos(phase * .7) * 14; }
if (s.motion === 'bounce') y += Math.abs(Math.sin(local * Math.PI * 2.2)) * -28 * (1 - local);
if (s.motion === 'scroll') y += this.scrollEnergy * (1 - local) * -80;
if (s.motion === 'pendulum') { x += Math.sin(phase * 1.5 + local * 3) * 32 * (1 - local); y += Math.abs(Math.cos(phase + local * 2)) * 14 * (1 - local); }
if (s.motion === 'rain') { y += local * local * 105 * g; x += Math.sin(phase * .65) * 7 * d; }
if (s.motion === 'orbit') { x += Math.cos(phase + local * 4) * 28 * (1 - local); y += Math.sin(phase + local * 4) * 20 * (1 - local); }
if (s.motion === 'jitter') { x += Math.sin(phase * 8.5) * 10 * turb; y += Math.cos(phase * 7.2) * 10 * turb; }
if (s.motion === 'cascade') { y += local * local * 78 * g; x += Math.sin(phase + local * 8) * 18 * (1 - local) * d; }
if (s.motion === 'spiral') {
const spiral = (1 - local) * (1 - local);
x += Math.cos(phase * .8 + local * 7) * 34 * spiral;
y += Math.sin(phase * .8 + local * 7) * 26 * spiral;
}
if (s.motion === 'gravity' || s.motion === 'blob' || s.motion === 'scroll') y += local * local * 42 * g;
const dx = x - this.pointer.x, dy = y - this.pointer.y;
const distance = Math.hypot(dx, dy), radius = 120;
const mode = s.interaction || 'none';
if (distance < radius && mode !== 'none') {
const force = Math.pow(1 - distance / radius, 2) * (mode === 'stretch' ? 34 : 22);
if (mode === 'attract') { x -= dx / (distance || 1) * force; y -= dy / (distance || 1) * force; }
if (mode === 'repel') { x += dx / (distance || 1) * force; y += dy / (distance || 1) * force; }
if (mode === 'stretch') { x += dx * .18; y += dy * .18; }
if (mode === 'ripple') { x += Math.sin(distance * .12 - this.time * 5) * force * .45; y += Math.cos(distance * .12 - this.time * 5) * force * .45; }
}
return { x, y, local };
}
return { x: p.tx + Math.sin(this.time * .8 + p.wobble) * .35, y: p.ty + Math.sin(this.time * 1.1 + p.wobble) * .35, local };
}
drawBursts() {
this.bursts = this.bursts.filter(b => b.life > 0);
this.bursts.forEach(b => {
b.life -= .025;
this.ctx.save();
this.ctx.globalAlpha = b.life;
this.ctx.strokeStyle = this.config.colors[0];
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.arc(b.x, b.y, (1 - b.life) * b.radius, 0, Math.PI * 2);
this.ctx.stroke();
this.ctx.restore();
});
}
frame(now) {
if (!this.running) return;
if (!this.last) this.last = now;
const dt = Math.min((now - this.last) / 16.67, 2);
this.last = now;
const spd = Number(this.config.speed || 1);
this.time += .018 * dt * spd;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) this.progress = 1;
else if (this.progress < 1) this.progress = Math.min(1, this.progress + .00095 * dt * spd);
this.scrollEnergy *= .94;
this.drawBackground();
const l = this.layer.getContext('2d');
l.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
l.clearRect(0, 0, this.W, this.H);
const visc = Number(this.config.viscosity || 0);
const multi = this.config.multi !== 'off' && this.config.multi !== false;
this.particles.forEach((p, i) => {
const q = this.positionOf(p);
if (q.local <= 0) return;
const landing = this.clamp(q.local * 2.5, 0, 1);
const r = p.size * (q.local < 1 ? 1 : this.lerp(.92, 1.1 + visc * .25, landing));
const rotation = q.local < 1
? Math.atan2(q.y - p.sy, q.x - p.sx) + Math.PI / 2
: Math.sin(this.time + p.wobble) * (.06 + visc * .12);
l.fillStyle = multi ? this.config.colors[i % this.config.colors.length] : this.config.colors[0];
const rx = q.local < 1 ? r : r;
const ry = q.local < 1 ? r * 1.45 : r * (this.config.motion === 'blob' ? 1.15 + Math.sin(this.time * 2 + p.phase) * .12 : 1.15);
this.drawShape(l, q.x, q.y, rx, ry, this.config.shape, rotation);
});
const mb = Number(this.config.mergeBlur) || 1.45;
const extraBlur = Number(this.config.blur) || 0;
const glow = Number(this.config.glow) || 0;
this.ctx.save();
if (glow) { this.ctx.shadowColor = this.config.colors[0]; this.ctx.shadowBlur = glow; }
this.ctx.filter = `blur(${mb + extraBlur}px) contrast(8)`; this.ctx.globalAlpha = .72; this.ctx.drawImage(this.layer, 0, 0, this.W, this.H);
this.ctx.filter = extraBlur ? `blur(${.45 + extraBlur}px)` : 'blur(.45px)'; this.ctx.globalAlpha = .9; this.ctx.drawImage(this.layer, 0, 0, this.W, this.H);
this.ctx.filter = extraBlur ? `blur(${extraBlur}px)` : 'none'; this.ctx.globalAlpha = .62; this.ctx.drawImage(this.layer, 0, 0, this.W, this.H);
this.ctx.restore();
this.drawBursts();
requestAnimationFrame(t => this.frame(t));
}
}
customElements.define('dripping-text-widget', DrippingTextWidget);
})();