Skip to content

Instantly share code, notes, and snippets.

@imliam
Created January 15, 2026 13:43
Show Gist options
  • Select an option

  • Save imliam/6f7de6224ae9cd8bea43936ff554bb93 to your computer and use it in GitHub Desktop.

Select an option

Save imliam/6f7de6224ae9cd8bea43936ff554bb93 to your computer and use it in GitHub Desktop.
@props([
'viewBox' => null,
'trigger' => 'hover',
'duration' => 300,
'easing' => 'easeInOut',
'event' => 'morph-icon',
'initial' => 0,
])
<div
x-data="svgMorph({
trigger: @js($trigger),
duration: @js($duration),
easing: @js($easing),
viewBox: @js($viewBox),
initial: @js($initial),
})"
x-on:mouseenter="handleHoverEnter()"
x-on:mouseleave="handleHoverLeave()"
x-on:click="handleClick()"
x-on:{{ $event }}.window="morph($event.detail?.index ?? (currentIndex === 0 ? 1 : 0))"
{{ $attributes->class(['inline-flex']) }}
>
<div x-ref="icons" class="hidden">{{ $slot }}</div>
<svg
x-ref="svg"
x-cloak
x-show="currentPaths.length > 0"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{{ $attributes->only(['class']) }}
></svg>
</div>
import { interpolate, interpolateAll } from 'flubber';
declare global {
interface Window {
Alpine: typeof import('alpinejs').default;
}
}
type EasingFunction = (t: number) => number;
interface PathState {
paths: string[];
viewBox: string | null;
}
interface MorphConfig {
trigger: 'hover' | 'click' | 'event';
duration: number;
easing: string;
viewBox: string | null;
initial: number | 'dark-mode';
}
const easingFunctions: Record<string, EasingFunction> = {
linear: (t) => t,
easeIn: (t) => t * t * t,
easeOut: (t) => 1 - Math.pow(1 - t, 3),
easeInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
easeInQuad: (t) => t * t,
easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
easeInBack: (t) => 2.70158 * t * t * t - 1.70158 * t * t,
easeOutBack: (t) => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2),
};
function getEasing(name: string): EasingFunction {
return easingFunctions[name] ?? easingFunctions.easeInOut;
}
function splitSubpaths(d: string): string[] {
const subpaths: string[] = [];
const regex = /M[^M]*/gi;
let match;
while ((match = regex.exec(d)) !== null) {
const subpath = match[0].trim();
if (subpath) {
subpaths.push(subpath);
}
}
return subpaths.length > 0 ? subpaths : [d];
}
function extractPathData(svgElement: Element): PathState {
const pathElements = Array.from(svgElement.querySelectorAll('path'));
const allSubpaths: string[] = [];
for (const p of pathElements) {
const d = p.getAttribute('d');
if (d && d.trim()) {
const subpaths = splitSubpaths(d);
allSubpaths.push(...subpaths);
}
}
const viewBox = svgElement.getAttribute('viewBox');
return { paths: allSubpaths, viewBox };
}
function padArrays(from: string[], to: string[]): [string[], string[]] {
const maxLength = Math.max(from.length, to.length);
const paddedFrom = [...from];
const paddedTo = [...to];
while (paddedFrom.length < maxLength) {
paddedFrom.push(paddedFrom[paddedFrom.length - 1] || 'M0,0');
}
while (paddedTo.length < maxLength) {
paddedTo.push(paddedTo[paddedTo.length - 1] || 'M0,0');
}
return [paddedFrom, paddedTo];
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('svgMorph', (config: MorphConfig) => ({
states: [] as string[][],
currentIndex: 0,
currentPaths: [] as string[],
pathElements: [] as SVGPathElement[],
viewBox: config.viewBox ?? '0 0 24 24',
isAnimating: false,
prefersReducedMotion: false,
init() {
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const container = this.$refs.icons as HTMLElement;
const svg = this.$refs.svg as SVGSVGElement;
if (!container || !svg) return;
const svgs = container.querySelectorAll('svg');
if (svgs.length === 0) return;
let maxPaths = 0;
svgs.forEach((svgEl, index) => {
const pathData = extractPathData(svgEl);
this.states.push(pathData.paths);
maxPaths = Math.max(maxPaths, pathData.paths.length);
if (index === 0 && !config.viewBox && pathData.viewBox) {
this.viewBox = pathData.viewBox;
}
});
svg.setAttribute('viewBox', this.viewBox);
for (let i = 0; i < maxPaths; i++) {
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svg.appendChild(pathEl);
this.pathElements.push(pathEl);
}
if (this.states.length > 0) {
let startIndex = 0;
if (config.initial === 'dark-mode') {
startIndex = document.documentElement.classList.contains('dark') ? 1 : 0;
} else {
startIndex = Math.min(config.initial, this.states.length - 1);
}
this.currentIndex = startIndex;
this.currentPaths = [...this.states[startIndex]];
this.updatePathElements();
}
},
updatePathElements() {
this.pathElements.forEach((el, i) => {
const d = this.currentPaths[i] || '';
el.setAttribute('d', d);
});
},
morph(toIndex: number) {
if (toIndex === this.currentIndex || this.isAnimating) return;
if (toIndex < 0 || toIndex >= this.states.length) return;
const fromPaths = this.states[this.currentIndex];
const toPaths = this.states[toIndex];
if (this.prefersReducedMotion) {
this.currentPaths = [...toPaths];
this.updatePathElements();
this.currentIndex = toIndex;
return;
}
const [paddedFrom, paddedTo] = padArrays(fromPaths, toPaths);
const interpolators =
paddedFrom.length === 1
? [interpolate(paddedFrom[0], paddedTo[0], { maxSegmentLength: 2 })]
: interpolateAll(paddedFrom, paddedTo, { maxSegmentLength: 2 });
this.isAnimating = true;
const easingFn = getEasing(config.easing);
const duration = config.duration;
const startTime = performance.now();
const animate = (now: number) => {
const elapsed = now - startTime;
const linearProgress = Math.min(elapsed / duration, 1);
const easedProgress = easingFn(linearProgress);
this.currentPaths = interpolators.map((interp) => interp(easedProgress));
this.updatePathElements();
if (linearProgress < 1) {
requestAnimationFrame(animate);
} else {
this.currentPaths = [...toPaths];
this.updatePathElements();
this.currentIndex = toIndex;
this.isAnimating = false;
}
};
requestAnimationFrame(animate);
},
toggle() {
const nextIndex = (this.currentIndex + 1) % this.states.length;
this.morph(nextIndex);
},
handleHoverEnter() {
if (config.trigger === 'hover') {
this.morph(1);
}
},
handleHoverLeave() {
if (config.trigger === 'hover') {
this.morph(0);
}
},
handleClick() {
if (config.trigger === 'click') {
this.toggle();
}
},
}));
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment