UNPKG

glitch-text-effect

Version:

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

537 lines (531 loc) 17.3 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var react = require('react'); /** * 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(); }); } // Simple className utility (replaces cn from external lib) const cn = (...classes) => classes.filter(Boolean).join(' '); /** * React component wrapper for the glitch text effect */ const GlitchText = react.forwardRef((props, ref) => { const { as = 'span', from, to, trigger = 'hover', className, children, onStart, onProgress, onComplete, ...restProps } = props; const Component = as; const elementRef = react.useRef(null); const glitchInstanceRef = react.useRef(null); const [isClient, setIsClient] = react.useState(false); // Handle SSR react.useEffect(() => { setIsClient(true); }, []); // Initialize glitch instance react.useEffect(() => { if (!isClient || !elementRef.current) return; const element = elementRef.current; glitchInstanceRef.current = createGlitch(element, { from, to, trigger, onStart, onProgress, onComplete, ...restProps }); return () => { glitchInstanceRef.current?.destroy(); glitchInstanceRef.current = null; }; }, [isClient, from, to, trigger, onStart, onProgress, onComplete, restProps]); // Handle trigger events const handleMouseEnter = react.useCallback(() => { if (trigger === 'hover' && glitchInstanceRef.current) { glitchInstanceRef.current.start(); } }, [trigger]); const handleMouseLeave = react.useCallback(() => { if (trigger === 'hover' && glitchInstanceRef.current) { glitchInstanceRef.current.reset(); } }, [trigger]); const handleClick = react.useCallback(() => { if (trigger === 'click' && glitchInstanceRef.current) { if (glitchInstanceRef.current.isRunning()) { glitchInstanceRef.current.reset(); } else { glitchInstanceRef.current.start(); } } }, [trigger]); // Intersection Observer for 'intersection' trigger react.useEffect(() => { if (trigger !== 'intersection' || !isClient || !elementRef.current) return; const element = elementRef.current; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && glitchInstanceRef.current) { glitchInstanceRef.current.start(); } }); }, { threshold: 0.5 }); observer.observe(element); return () => { observer.disconnect(); }; }, [trigger, isClient]); // Expose methods via ref react.useEffect(() => { if (typeof ref === 'function') { ref(elementRef.current); } else if (ref) { ref.current = elementRef.current; } }, [ref]); // Event handlers based on trigger type const eventHandlers = { ...(trigger === 'hover' && { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }), ...(trigger === 'click' && { onClick: handleClick }) }; return (jsxRuntime.jsx(Component, { ref: elementRef, className: cn('inline-block', className), ...eventHandlers, ...restProps, children: !isClient ? (children || from) : null })); }); GlitchText.displayName = 'GlitchText'; /** * Hook for manual control of glitch effect */ function useGlitchText(config) { const [element, setElement] = react.useState(null); const glitchInstanceRef = react.useRef(null); react.useEffect(() => { if (!element) return; glitchInstanceRef.current = createGlitch(element, { ...config, trigger: 'manual' }); return () => { glitchInstanceRef.current?.destroy(); glitchInstanceRef.current = null; }; }, [element, config]); const start = react.useCallback(() => { glitchInstanceRef.current?.start(); }, []); const stop = react.useCallback(() => { glitchInstanceRef.current?.stop(); }, []); const reset = react.useCallback(() => { glitchInstanceRef.current?.reset(); }, []); const isRunning = react.useCallback(() => { return glitchInstanceRef.current?.isRunning() ?? false; }, []); return { ref: setElement, start, stop, reset, isRunning }; } exports.GlitchText = GlitchText; exports.createGlitch = createGlitch; exports.glitch = glitch; exports.useGlitchText = useGlitchText; //# sourceMappingURL=react.js.map