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
JavaScript
'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