Files
hamburger_morphing/hamburger.mjs
2025-11-12 16:15:25 -04:00

333 lines
21 KiB
JavaScript

/**
* 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 = `<svg viewBox="0 0 100 100" width="${this.options.size}px" height="${this.options.size}px">\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 += ` <line x1="${x1}" x2="${x2}" y1="${y1}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="${strokeLinecap}" stroke-linejoin="${strokeLinejoin}" fill="${fill}" opacity="${opacity}"></line>\n`;
});
svgCode += `</svg>`;
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;
}
}