Skip to content

Commit ba00265

Browse files
committed
Add magnetic cursor follower effect for stat and role badges
The cursor follower now detects proximity to .stat-badge and .role-badge elements and fluidly morphs from a circle into a rectangle that fills the badge shape, inverting the text via the existing mix-blend-mode. - Badge element caching with fresh getBoundingClientRect per frame to avoid stale positions during page entrance transitions - Separate approach/retreat easing for fluid morph in, snappy return - Clean handoff to CSS link hover when moving from badge to link - Cache invalidation on section switch, resize, scroll, and badge creation
1 parent 4b29176 commit ba00265

1 file changed

Lines changed: 138 additions & 13 deletions

File tree

index.html

Lines changed: 138 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2180,6 +2180,7 @@
21802180
const targetSection = document.getElementById(targetId);
21812181
if (targetSection) {
21822182
targetSection.classList.add('active');
2183+
if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true;
21832184
}
21842185
navItems.forEach(nav => {
21852186
nav.classList.remove('active');
@@ -2583,37 +2584,160 @@
25832584
const cursorFollower = document.getElementById('cursorFollower');
25842585
const isTouchDevice = ('ontouchstart' in window || navigator.maxTouchPoints > 0) && window.matchMedia('(hover: none)').matches;
25852586

2587+
var badgeRectsDirty = true;
2588+
25862589
if (!isTouchDevice) {
2587-
let mouseX = 0;
2588-
let mouseY = 0;
2589-
let followerX = 0;
2590-
let followerY = 0;
2591-
const ease = 0.15;
2590+
var mouseX = 0;
2591+
var mouseY = 0;
2592+
var followerX = 0;
2593+
var followerY = 0;
2594+
var badgeElements = [];
2595+
var activeBadge = null;
2596+
2597+
var MAGNETIC_RADIUS = 20;
2598+
var EASE_DEFAULT = 0.15;
2599+
var EASE_ATTRACT = 0.12;
2600+
var BASE_SIZE = 12;
2601+
var SHAPE_EASE_IN = 0.07;
2602+
var SHAPE_EASE_OUT = 0.15;
2603+
2604+
// Lerped shape state for fluid morph
2605+
var currentScaleX = 1;
2606+
var currentScaleY = 1;
2607+
var currentBorderRadius = 6; // px (6 = circle for 12px element)
2608+
2609+
function distToRect(px, py, r) {
2610+
var dx = Math.max(r.left - px, 0, px - r.right);
2611+
var dy = Math.max(r.top - py, 0, py - r.bottom);
2612+
return Math.sqrt(dx * dx + dy * dy);
2613+
}
2614+
2615+
function cacheBadgeElements() {
2616+
var badges = document.querySelectorAll('.stat-badge, .role-badge');
2617+
badgeElements = [];
2618+
badges.forEach(function(badge) {
2619+
var section = badge.closest('section');
2620+
if (section && !section.classList.contains('active')) return;
2621+
badgeElements.push(badge);
2622+
});
2623+
badgeRectsDirty = false;
2624+
}
25922625

2593-
document.addEventListener('mousemove', (e) => {
2626+
document.addEventListener('mousemove', function(e) {
25942627
mouseX = e.clientX;
25952628
mouseY = e.clientY;
25962629
});
25972630

25982631
function animateFollower() {
2599-
followerX += (mouseX - followerX) * ease;
2600-
followerY += (mouseY - followerY) * ease;
2632+
if (badgeRectsDirty) cacheBadgeElements();
2633+
2634+
// Find closest badge (fresh rects every frame — no stale positions)
2635+
var closestDist = MAGNETIC_RADIUS;
2636+
var closestRect = null;
2637+
var closestEl = null;
2638+
for (var i = 0; i < badgeElements.length; i++) {
2639+
var rect = badgeElements[i].getBoundingClientRect();
2640+
if (rect.width === 0 || rect.height === 0) continue;
2641+
var d = distToRect(mouseX, mouseY, rect);
2642+
if (d < closestDist) {
2643+
closestDist = d;
2644+
closestRect = rect;
2645+
closestEl = badgeElements[i];
2646+
}
2647+
}
2648+
2649+
// Compute magnetic pull
2650+
var targetX = mouseX;
2651+
var targetY = mouseY;
2652+
var strength = 0;
2653+
var targetScaleX = 1;
2654+
var targetScaleY = 1;
2655+
var targetRadius = 6; // px (6 = circle)
2656+
2657+
if (closestRect) {
2658+
strength = 1 - closestDist / MAGNETIC_RADIUS;
2659+
strength = strength * strength; // quadratic ease-in
2660+
var cx = closestRect.left + closestRect.width / 2;
2661+
var cy = closestRect.top + closestRect.height / 2;
2662+
targetX = mouseX + (cx - mouseX) * strength;
2663+
targetY = mouseY + (cy - mouseY) * strength;
2664+
2665+
// Target: fill inside the badge
2666+
targetScaleX = 1 + (closestRect.width / BASE_SIZE - 1) * strength;
2667+
targetScaleY = 1 + (closestRect.height / BASE_SIZE - 1) * strength;
2668+
// Lerp toward 2px border-radius (matches badge border-radius)
2669+
targetRadius = 6 + (2 - 6) * strength;
2670+
}
2671+
2672+
// Lerp position
2673+
var ease = closestRect ? EASE_ATTRACT : EASE_DEFAULT;
2674+
followerX += (targetX - followerX) * ease;
2675+
followerY += (targetY - followerY) * ease;
2676+
2677+
// Lerp shape — slow approach, fast retreat
2678+
var shapeEase = closestRect ? SHAPE_EASE_IN : SHAPE_EASE_OUT;
2679+
currentScaleX += (targetScaleX - currentScaleX) * shapeEase;
2680+
currentScaleY += (targetScaleY - currentScaleY) * shapeEase;
2681+
currentBorderRadius += (targetRadius - currentBorderRadius) * shapeEase;
2682+
2683+
// Position (same as original — left/top)
26012684
cursorFollower.style.left = followerX + 'px';
26022685
cursorFollower.style.top = followerY + 'px';
2686+
2687+
// Track proximity separately from visual morph
2688+
activeBadge = closestEl || null;
2689+
2690+
// Shape: only apply transform/borderRadius when morphing
2691+
var isBlob = Math.abs(currentScaleX - 1) > 0.01 || Math.abs(currentScaleY - 1) > 0.01;
2692+
2693+
if (isBlob && closestRect) {
2694+
// Near a badge: morph and suppress link hover
2695+
cursorFollower.classList.remove('hovering');
2696+
cursorFollower.style.transform =
2697+
'translate(-50%, -50%) scale(' + currentScaleX.toFixed(3) + ',' + currentScaleY.toFixed(3) + ')';
2698+
cursorFollower.style.borderRadius = currentBorderRadius.toFixed(1) + 'px';
2699+
} else if (isBlob && !closestRect && cursorFollower.classList.contains('hovering')) {
2700+
// Left badge, now on a link: snap morph to default so CSS hover works
2701+
currentScaleX = 1;
2702+
currentScaleY = 1;
2703+
currentBorderRadius = 6;
2704+
cursorFollower.style.transform = '';
2705+
cursorFollower.style.borderRadius = '';
2706+
} else if (isBlob) {
2707+
// Left badge, empty space: smooth retreat animation
2708+
cursorFollower.style.transform =
2709+
'translate(-50%, -50%) scale(' + currentScaleX.toFixed(3) + ',' + currentScaleY.toFixed(3) + ')';
2710+
cursorFollower.style.borderRadius = currentBorderRadius.toFixed(1) + 'px';
2711+
} else {
2712+
// Not morphing: clean default
2713+
currentScaleX = 1;
2714+
currentScaleY = 1;
2715+
currentBorderRadius = 6;
2716+
cursorFollower.style.transform = '';
2717+
cursorFollower.style.borderRadius = '';
2718+
}
2719+
26032720
requestAnimationFrame(animateFollower);
26042721
}
26052722

26062723
animateFollower();
26072724

2608-
const interactiveElements = document.querySelectorAll('a, button, .theme-toggle');
2609-
interactiveElements.forEach(el => {
2610-
el.addEventListener('mouseenter', () => {
2611-
cursorFollower.classList.add('hovering');
2725+
// Link hover — original behaviour (CSS width/height transition)
2726+
var interactiveElements = document.querySelectorAll('a, button, .theme-toggle');
2727+
interactiveElements.forEach(function(el) {
2728+
el.addEventListener('mouseenter', function() {
2729+
if (!activeBadge) cursorFollower.classList.add('hovering');
26122730
});
2613-
el.addEventListener('mouseleave', () => {
2731+
el.addEventListener('mouseleave', function() {
26142732
cursorFollower.classList.remove('hovering');
26152733
});
26162734
});
2735+
2736+
// Invalidate badge element cache on scroll and resize
2737+
sections.forEach(function(s) {
2738+
s.addEventListener('scroll', function() { badgeRectsDirty = true; }, { passive: true });
2739+
});
2740+
window.addEventListener('resize', function() { badgeRectsDirty = true; }, { passive: true });
26172741
}
26182742

26192743

@@ -2767,6 +2891,7 @@
27672891
el.innerHTML =
27682892
'<span class="stat-badge"><svg viewBox="0 0 16 16" fill="currentColor"><path d="' + STAR_PATH + '"/></svg> ' + stars + '</span>' +
27692893
'<span class="stat-badge"><svg viewBox="0 0 16 16" fill="currentColor"><path d="' + FORK_PATH + '"/></svg> ' + forks + '</span>';
2894+
if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true;
27702895
}
27712896

27722897
els.forEach(function(el) {

0 commit comments

Comments
 (0)