UNPKG

glitch-text-effect

Version:

A lightweight, customizable glitch text effect library with zero dependencies. Framework-agnostic core with React wrapper.

407 lines (403 loc) 13 kB
'use strict'; /** * Predefined character sets for glitch effect */ const CHARACTER_SETS = { letters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', numbers: '0123456789', symbols: '!@#$%^&*()_+-=[]{}|;\':",./<>?`~', alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', all: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;\':",./<>?`~' }; /** * Timing functions for animation easing */ const TIMING_FUNCTIONS = { linear: (t) => t, easeIn: (t) => t * t, easeOut: (t) => 1 - Math.pow(1 - t, 2), easeInOut: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 }; /** * Default configuration values based on intensity */ const INTENSITY_PRESETS = { low: { duration: 800, revealRate: 0.7, glitchRate: 0.3, effects: { shake: false, flicker: false } }, medium: { duration: 1200, revealRate: 0.5, glitchRate: 0.6, effects: { shake: true, flicker: false } }, high: { duration: 1800, revealRate: 0.3, glitchRate: 0.9, effects: { shake: true, flicker: true, colorShift: true } } }; /** * Get character set string from CharacterSet type or custom string */ function getCharacterSet(characters) { if (typeof characters === 'string' && characters.length > 0) { return CHARACTER_SETS[characters] || characters; } return CHARACTER_SETS.letters; } /** * Get timing function from TimingFunction type or custom function */ function getTimingFunction(timing) { if (typeof timing === 'function') { return timing; } return TIMING_FUNCTIONS[timing] || TIMING_FUNCTIONS.linear; } /** * Check if user prefers reduced motion */ function prefersReducedMotion() { if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } /** * Clamp value between min and max */ function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } /** * Get intensity preset configuration */ function getIntensityPreset(intensity) { return INTENSITY_PRESETS[intensity]; } /** * Normalize text lengths by padding shorter text with spaces */ function normalizeTextLengths(from, to) { const maxLength = Math.max(from.length, to.length); const normalizedFrom = from.padEnd(maxLength, ' '); const normalizedTo = to.padEnd(maxLength, ' '); return [normalizedFrom, normalizedTo]; } /** * Calculate if character should be revealed based on progress and reveal rate */ function shouldRevealCharacter(index, textLength, progress, revealRate) { const normalizedIndex = index / textLength; const revealThreshold = progress * (1 + revealRate); return normalizedIndex <= revealThreshold; } /** * Default configuration for glitch effect */ const DEFAULT_CONFIG = { duration: 1200, trigger: "hover", intensity: "medium", characters: "letters", timing: "easeOut", revealRate: 0.5, glitchRate: 0.6, effects: { shake: true, flicker: false, colorShift: false, scalePulse: false, }, respectReducedMotion: true, className: "", }; /** * Core glitch engine - framework agnostic */ class GlitchEngine { constructor(element, config) { this.colorShiftConfig = null; this.currentColorIndex = 0; /** * Main animation loop */ this.animate = () => { if (!this.state.isRunning) return; const elapsed = performance.now() - this.state.startTime; const progress = clamp(elapsed / this.config.duration, 0, 1); const easedProgress = this.timingFunction(progress); // Generate current frame text this.generateFrameText(easedProgress); // Handle color shifting this.updateColorShift(progress); // Progress callback this.config.onProgress?.(progress); // Check if animation is complete if (progress >= 1) { this.state.isRunning = false; this.state.currentText = this.targetText; this.updateElementText(this.targetText); this.applyVisualEffects(false); this.config.onComplete?.(); return; } // Schedule next frame this.state.animationId = requestAnimationFrame(this.animate); }; this.element = element; this.originalText = config.from; this.targetText = config.to; // Merge with intensity preset and defaults const intensityPreset = config.intensity ? getIntensityPreset(config.intensity) : {}; this.config = { ...DEFAULT_CONFIG, ...intensityPreset, ...config, effects: { ...DEFAULT_CONFIG.effects, ...intensityPreset.effects, ...(config.effects || {}), }, }; // Normalize text lengths const [normalizedFrom, normalizedTo] = normalizeTextLengths(this.originalText, this.targetText); this.originalText = normalizedFrom; this.targetText = normalizedTo; // Setup character set and timing function this.characterSet = getCharacterSet(this.config.characters); this.timingFunction = getTimingFunction(this.config.timing); // Setup color shift configuration if (this.config.effects.colorShift) { if (typeof this.config.effects.colorShift === "boolean") { this.colorShiftConfig = { enabled: true, colors: [ "#ff0080", "#00ff80", "#8000ff", "#ff8000", "#0080ff", "#ffffff", ], speed: 1, }; } else { this.colorShiftConfig = { colors: [ "#ff0080", "#00ff80", "#8000ff", "#ff8000", "#0080ff", "#ffffff", ], speed: 1, ...this.config.effects.colorShift, }; } } // Initialize state this.state = { isRunning: false, startTime: 0, currentText: this.originalText, revealedIndices: new Set(), }; // Set initial text this.updateElementText(this.originalText); } /** * Start the glitch animation */ start() { if (this.state.isRunning) return; // Respect reduced motion preference if (this.config.respectReducedMotion && prefersReducedMotion()) { this.updateElementText(this.targetText); this.config.onComplete?.(); return; } this.state.isRunning = true; this.state.startTime = performance.now(); this.state.revealedIndices.clear(); // Apply initial effects this.applyVisualEffects(true); // Start animation callback this.config.onStart?.(); this.animate(); } /** * Stop the animation */ stop() { if (!this.state.isRunning) return; this.state.isRunning = false; if (this.state.animationId) { cancelAnimationFrame(this.state.animationId); this.state.animationId = undefined; } this.applyVisualEffects(false); } /** * Reset to original state */ reset() { this.stop(); this.state.currentText = this.originalText; this.state.revealedIndices.clear(); this.updateElementText(this.originalText); } /** * Check if animation is running */ isRunning() { return this.state.isRunning; } /** * Destroy the instance and clean up */ destroy() { this.stop(); this.applyVisualEffects(false); } /** * Generate text for current animation frame */ generateFrameText(progress) { const textLength = this.targetText.length; let frameText = ""; for (let i = 0; i < textLength; i++) { const shouldReveal = shouldRevealCharacter(i, textLength, progress, this.config.revealRate); if (shouldReveal && !this.state.revealedIndices.has(i)) { this.state.revealedIndices.add(i); } if (this.state.revealedIndices.has(i)) { frameText += this.targetText[i]; } else if (Math.random() < this.config.glitchRate) { frameText += this.getRandomCharacterForPosition(i); } else { frameText += this.originalText[i] || " "; } } this.state.currentText = frameText; this.updateElementText(frameText); } /** * Get random character for specific position (maintains some consistency) */ getRandomCharacterForPosition(_index) { // Use index for more consistent randomization per position return this.characterSet[Math.floor(Math.random() * this.characterSet.length)]; } /** * Update element text content */ updateElementText(text) { if (this.element.textContent !== text) { this.element.textContent = text; } } /** * Update color shifting effect */ updateColorShift(_progress) { if (!this.colorShiftConfig?.enabled || !this.colorShiftConfig.colors) return; const { colors, speed = 1 } = this.colorShiftConfig; const colorChangeInterval = 100 * speed; // Base interval in ms const elapsed = performance.now() - this.state.startTime; // Change color at intervals if (Math.floor(elapsed / colorChangeInterval) !== this.currentColorIndex) { this.currentColorIndex = Math.floor(Math.random() * colors.length); this.element.style.color = colors[this.currentColorIndex]; } } /** * Apply or remove visual effects */ applyVisualEffects(apply) { const { effects } = this.config; if (effects.shake) { this.element.style.animation = apply ? "glitch-shake 0.1s infinite" : ""; } if (effects.flicker) { this.element.style.animation = apply ? `${this.element.style.animation} glitch-flicker 0.15s infinite`.trim() : this.element.style.animation .replace("glitch-flicker 0.15s infinite", "") .trim(); } if (effects.scalePulse) { this.element.style.animation = apply ? `${this.element.style.animation} glitch-scale-pulse 0.3s infinite`.trim() : this.element.style.animation .replace("glitch-scale-pulse 0.3s infinite", "") .trim(); } // Color shift is now handled dynamically in updateColorShift method if (!apply && this.colorShiftConfig?.enabled) { this.element.style.color = ""; // Reset color } // Apply custom class if (this.config.className) { if (apply) { this.element.classList.add(...this.config.className.split(" ")); } else { this.element.classList.remove(...this.config.className.split(" ")); } } } } /** * Factory function to create glitch instance */ function createGlitch(element, config) { const engine = new GlitchEngine(element, config); return { start: () => engine.start(), stop: () => engine.stop(), reset: () => engine.reset(), isRunning: () => engine.isRunning(), destroy: () => engine.destroy(), }; } /** * Convenience function for quick glitch effect */ function glitch(element, config) { return new Promise((resolve) => { const instance = createGlitch(element, { ...config, onComplete: () => { config.onComplete?.(); resolve(); }, }); instance.start(); }); } exports.CHARACTER_SETS = CHARACTER_SETS; exports.GlitchEngine = GlitchEngine; exports.INTENSITY_PRESETS = INTENSITY_PRESETS; exports.TIMING_FUNCTIONS = TIMING_FUNCTIONS; exports.createGlitch = createGlitch; exports.getCharacterSet = getCharacterSet; exports.getIntensityPreset = getIntensityPreset; exports.getTimingFunction = getTimingFunction; exports.glitch = glitch; exports.prefersReducedMotion = prefersReducedMotion; //# sourceMappingURL=core.js.map