* * * 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); })();