/** * Hamburger Morphing Animation Module * A lightweight ES module for creating animated hamburger menu icons using SVG.js */ // Configuration constants - easily adjustable const STROKE_WIDTH = 29; const ANIMATION_DURATION = 500; // Default colors for light and dark mode compatibility const DEFAULT_FOREGROUND = "#000000"; const DEFAULT_BACKGROUND = "#ffffff"; const DEFAULT_HOVER = "#ff0000"; export class HamburgerMorphing { /** * Creates a new hamburger morphing animation instance * @param {string} containerSelector - CSS selector for the container element * @param {Object} options - Configuration options * @param {number} [options.size=100] - Size of the SVG in pixels * @param {string} [options.foreground="#000000"] - Foreground/stroke color for the lines (supports CSS variables) * @param {string} [options.background="#ffffff"] - Background color for cutout effects (supports CSS variables) * @param {string} [options.hover="#ff0000"] - Hover color for interactive effects (supports CSS variables) */ constructor(containerSelector, options = {}) { this.container = document.querySelector(containerSelector); if (!this.container) { throw new Error(`Container element not found: ${containerSelector}`); } this.options = { size: options.size || 100, foreground: this.resolveCssVariable(options.foreground || DEFAULT_FOREGROUND), background: this.resolveCssVariable(options.background || DEFAULT_BACKGROUND), hover: this.resolveCssVariable(options.hover || DEFAULT_HOVER) }; this.draw = null; this.line1 = null; this.line2 = null; this.line3 = null; this.stroke = { color: this.options.foreground, width: STROKE_WIDTH, linecap: "round", linejoin: "round", }; this.init(); // Add hover event listeners this.container.addEventListener('mouseenter', () => this.onHover(true)); this.container.addEventListener('mouseleave', () => this.onHover(false)); } /** * Resolves CSS variables to their computed color values * @param {string} color - Color value (can be CSS variable like 'var(--primary-color)' or hex like '#000000') * @returns {string} Resolved color value */ resolveCssVariable(color) { // Check if the color is a CSS variable if (color && color.trim().startsWith('var(')) { // Extract the variable name from var(--variable-name) const match = color.match(/var\(\s*(--[^,\s)]+)/); if (match) { const variableName = match[1]; // Get the computed style from the document root const computedValue = getComputedStyle(document.documentElement) .getPropertyValue(variableName) .trim(); // Return the computed value if found, otherwise return the original return computedValue || color; } } // Return the color as-is if it's not a CSS variable return color; } /** * Initializes the SVG drawing and creates the initial hamburger lines */ init() { // Clear any existing content this.container.innerHTML = ''; // Create SVG drawing - expand to fill container this.draw = SVG().addTo(this.container).size('100%', '100%').viewbox(0, 0, 100, 100); // Create the three lines this.line1 = this.draw.line(20, 20, 80, 20).fill(this.options.background).stroke(this.stroke); this.line2 = this.draw.line(20, 50, 80, 50).fill(this.options.background).stroke(this.stroke); this.line3 = this.draw.line(20, 80, 80, 80).fill(this.options.background).stroke(this.stroke); } /** * Animates the hamburger icon to a specified shape * @param {string} shape - The target shape ('burger', 'x', 'plus', 'minus', 'arrow_left', 'arrow_right', 'arrow_up', 'arrow_down') */ animateTo(shape) { if (!this.line1 || !this.line2 || !this.line3) { throw new Error('HamburgerMorphing not properly initialized'); } switch (shape) { case 'x': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'plus': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'minus': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'burger': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'arrow_left': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 20, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 65, x2: 80, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 20, y1: 80, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'arrow_down': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 20, y2: 35, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 50, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'arrow_up': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 50, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 80, y2: 65, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 50, y1: 50, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'arrow_right': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 35, y1: 50, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 80, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'triangle_up': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'triangle_down': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'triangle_left': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'triangle_right': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'carrot_right': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'carrot_left': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 20, y1: 20, y2: 50, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 50, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'carrot_up': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'carrot_down': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 50, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 80, y1: 80, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'circle': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 90 , stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 90 , stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 90 , stroke: this.options.foreground }); break; case 'doughnut': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 90 }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 90 }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 50, x2: 50, y1: 50, y2: 50, 'stroke-width': 45, stroke: this.options.background }); break; case 'moon': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 60, x2: 60, y1: 50, y2: 50, 'stroke-width': 90 }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 60, x2: 60, y1: 50, y2: 50, 'stroke-width': 90 }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 90, x2: 90, y1: 50, y2: 50, 'stroke-width': 90, stroke: this.options.background }); break; case 'u': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'u_up': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'u_left': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'u_right': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'hamburger_vertical': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 20, y2: 20, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 80, y1: 80, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; case 'hamburger_horizontal': this.line1.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line2.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 20, x2: 20, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); this.line3.animate(ANIMATION_DURATION, 0, "now").attr({ x1: 80, x2: 80, y1: 20, y2: 80, 'stroke-width': STROKE_WIDTH, stroke: this.options.foreground }); break; default: throw new Error(`Unknown shape: ${shape}`); } } /** * Returns the current SVG code as a string * @returns {string} SVG markup */ getSvgCode() { const svgElement = this.container.querySelector('svg'); if (!svgElement) return ''; const lines = svgElement.querySelectorAll('line'); let svgCode = `\n`; lines.forEach(line => { const x1 = Math.round(line.getAttribute('x1')); const y1 = Math.round(line.getAttribute('y1')); const x2 = Math.round(line.getAttribute('x2')); const y2 = Math.round(line.getAttribute('y2')); const stroke = line.getAttribute('stroke') || this.options.foreground; const strokeWidth = line.getAttribute('stroke-width') || STROKE_WIDTH.toString(); const strokeLinecap = line.getAttribute('stroke-linecap') || 'round'; const strokeLinejoin = line.getAttribute('stroke-linejoin') || 'round'; const fill = line.getAttribute('fill') || '#fff'; const opacity = line.getAttribute('opacity') || '1'; svgCode += ` \n`; }); svgCode += ``; return svgCode; } /** * Handles hover state changes with animation * @param {boolean} isHovering - Whether the mouse is hovering */ onHover(isHovering) { if (!this.line1 || !this.line2 || !this.line3) return; const targetColor = isHovering ? this.options.hover : this.options.foreground; // Only change color for lines that have the foreground color (not background/cutout colors) const hoverDuration = ANIMATION_DURATION / 2; // Half the morphing time if (this.line1.attr('stroke') === this.options.foreground || this.line1.attr('stroke') === this.options.hover) { this.line1.animate(hoverDuration, 0, "now").attr({ stroke: targetColor }); } if (this.line2.attr('stroke') === this.options.foreground || this.line2.attr('stroke') === this.options.hover) { this.line2.animate(hoverDuration, 0, "now").attr({ stroke: targetColor }); } if (this.line3.attr('stroke') === this.options.foreground || this.line3.attr('stroke') === this.options.hover) { this.line3.animate(hoverDuration, 0, "now").attr({ stroke: targetColor }); } } /** * Destroys the hamburger morphing instance and cleans up the DOM */ destroy() { // Remove event listeners this.container.removeEventListener('mouseenter', () => this.onHover(true)); this.container.removeEventListener('mouseleave', () => this.onHover(false)); if (this.draw) { this.draw.remove(); this.draw = null; } this.line1 = null; this.line2 = null; this.line3 = null; } }