A smooth variable font hover effect where each character responds to the cursor, dynamically shifting weight based on distance. As you move closer, the text subtly becomes heavier or lighter, creating a soft, interactive feel.
Right-click in Elementor, choose “Paste from another site,” and while the popup is open, press cmd/ctrl + v to insert the layout.
Place the scripts before the body tag of your project. If you have added them before for another setup, skip this step.
<!-- GSAP Core -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
<!-- GSAP SplitText -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/SplitText.min.js"></script>Paste the script through Elementor → Custom Code and set it to load after the closing body tag.
document.addEventListener("DOMContentLoaded", () => {
if (document.body.classList.contains("elementor-editor-active")) return;
if (!window.gsap || !window.SplitText) return;
if (window.matchMedia("(hover: none), (pointer: coarse)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
gsap.registerPlugin(SplitText);
const defaults = {
min: 300,
max: 900,
distance: 160,
duration: 0.25,
ease: "power3.out",
mode: "gain"
};
const glyphs = [];
const pointer = { x: 0, y: 0 };
const clamp01 = gsap.utils.clamp(0, 1);
let active = false;
const getNum = (value, fallback) => {
const num = parseFloat(value);
return Number.isFinite(num) ? num : fallback;
};
const getMode = (value, fallback) => {
return ["gain", "loss"].includes(value) ? value : fallback;
};
const getCurrentWeight = (element) => {
const weight = window.getComputedStyle(element).fontWeight;
if (weight === "normal") return 400;
if (weight === "bold") return 700;
const num = parseFloat(weight);
return Number.isFinite(num) ? num : 400;
};
const measureAll = () => {
glyphs.forEach((glyph) => {
const rect = glyph.el.getBoundingClientRect();
glyph.x = rect.left + window.scrollX + rect.width / 2;
glyph.y = rect.top + window.scrollY + rect.height / 2;
});
};
gsap.utils.toArray('[data-vfont-hover="text"]').forEach((item) => {
const baseWeight = getCurrentWeight(item);
const min = getNum(item.dataset.vfontMin, defaults.min);
const max = getNum(item.dataset.vfontMax, defaults.max);
const distance = getNum(item.dataset.vfontDistance, defaults.distance);
const duration = getNum(item.dataset.vfontDuration, defaults.duration);
const ease = item.dataset.vfontEase || defaults.ease;
const mode = getMode(item.dataset.vfontMode, defaults.mode);
new SplitText(item, { type: "words,chars" }).chars.forEach((char) => {
gsap.set(char, {
"--vfont-wght": baseWeight,
fontVariationSettings: '"wght" var(--vfont-wght)'
});
glyphs.push({
el: char,
baseWeight,
min,
max,
distance,
mode,
x: 0,
y: 0,
setWeight: gsap.quickTo(char, "--vfont-wght", { duration, ease, overwrite: "auto" })
});
});
});
if (!glyphs.length) return;
let resizeTimer = null;
const debouncedMeasure = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(measureAll, 100);
};
window.addEventListener("pointermove", (e) => {
pointer.x = e.pageX;
pointer.y = e.pageY;
if (active) return;
active = true;
measureAll();
}, { passive: true });
window.addEventListener("resize", debouncedMeasure, { passive: true });
document.fonts?.ready?.then(measureAll).catch(() => {});
let resizeObserver = null;
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(debouncedMeasure);
glyphs.forEach(({ el }) => resizeObserver.observe(el));
}
const tick = () => {
if (!active) return;
glyphs.forEach((glyph) => {
const progress = clamp01(1 - Math.hypot(pointer.x - glyph.x, pointer.y - glyph.y) / glyph.distance);
const weight = glyph.mode === "loss"
? glyph.baseWeight + (glyph.min - glyph.baseWeight) * progress
: glyph.baseWeight + (glyph.max - glyph.baseWeight) * progress;
glyph.setWeight(weight);
});
};
gsap.ticker.add(tick);
window.addEventListener("pagehide", () => {
gsap.ticker.remove(tick);
resizeObserver?.disconnect();
clearTimeout(resizeTimer);
}, { once: true });
});Some solutions only work on the live site. Always publish and test after each change, as results may not appear in the editor.
This effect only works properly with a variable font that supports the wght axis. If the selected font does not support variable weight, the script can still run, but the animation may not be visible because the font weight cannot smoothly change.
Add data-vfont-hover="text" to the text element you want to animate. This is the only required attribute. The script will split the text into words and characters, then animate each character’s font weight based on how close the cursor is to it.
Mode controls whether the characters become heavier or lighter when the cursor gets close to them. By default, the value is gain. You can override this by adding data-vfont-mode="VALUE" to the text element. Use gain if you want nearby characters to become bolder, or use loss if you want nearby characters to become lighter.
Minimum weight controls how light the characters can become when the effect is using loss mode. By default, the value is 300.
The script first reads the current font weight from your CSS styling, then animates from that original weight down toward the minimum value when the cursor gets close. You can override this by adding data-vfont-min="VALUE" to the text element.
Lower values make the characters thinner, but the result depends on the weight range supported by your variable font.
Maximum weight controls how bold the characters can become when the effect is using gain mode. By default, the value is 900.
The script first reads the current font weight from your CSS styling, then animates from that original weight up toward the maximum value when the cursor gets close. You can override this by adding data-vfont-max="VALUE" to the text element.
Higher values make the characters heavier, but your font must support that weight for the animation to show correctly.
Distance controls how close the cursor needs to be before the characters start reacting. By default, the value is 160 px. You can override this by adding data-vfont-distance="VALUE" to the text element. Higher values create a wider reaction area, while lower values make the effect feel tighter and more focused.
Duration controls how long the font weight animation takes to move from one value to another. By default, the value is 0.25 seconds. You can override this by adding data-vfont-duration="VALUE" to the text element. Lower values feel snappier, while higher values feel smoother and more relaxed.
Ease controls the motion curve used when the font weight changes. By default, the value is power3.out. You can override this by adding data-vfont-ease="VALUE" to the text element. This accepts any valid GSAP easing value, so you can make the animation feel sharper, softer, or more playful depending on the effect you want.