2026-07-13
How my navbar logo stretches and melts
A walkthrough of the navbar wordmark animation: an SVG displacement filter driven from scroll position, a scaleY stretch sized to absorb exactly the scroll distance, and a rubber-band snap on scrollend.
Scroll to the very top of the homepage and the KERIMOV wordmark in the navbar stretches downward and its edges start to boil, like the letters are being pulled out of hot glass. Let go, and the page snaps back and the logo settles into its normal proportions. None of it is a video or a canvas. It is the actual navbar logo, and the whole effect is three ingredients: an SVG displacement filter, one scaleY transform, and a scroll listener that turns scroll position into a single progress number.
The logo is real vector text
The wordmark is not an image of the logo. It is an inline SVG in the header component with the letterforms as path elements, stroked with the site's --pageText variable. That matters twice: it repaints correctly when the random palette generator changes the site colors, and it can be run through an SVG filter, which a bitmap in an img tag cannot.
The melt is an SVG filter, not CSS
Inside the same SVG sits a filter made of two primitives. feTurbulence generates a field of Perlin-style noise, and feDisplacementMap uses that noise to push the logo's pixels around: the red channel of the noise displaces horizontally, the green channel vertically. The one knob that matters is scale, which is how far pixels are allowed to travel. It starts at 0, so at rest the filter is invisible and the logo renders crisp.
<filter id="displacementFilter">
<feTurbulence
type="turbulence"
baseFrequency="0.02"
numOctaves="3"
result="turbulence" />
<feDisplacementMap
id="displacement-2"
in2="turbulence"
in="SourceGraphic"
scale="0"
xChannelSelector="R"
yChannelSelector="G" />
</filter>The page starts one pixel past the effect
The animation is scroll-driven, so it needs scroll room. The homepage inserts an empty 190px spacer above the content and, on mount, immediately scrolls one pixel past it. The visitor lands on a normal-looking page, but there is now a hidden 190px zone above the fold. The only way to enter it is to keep scrolling up when you are already at the top, which is exactly the overscroll gesture people make anyway.
const logoScrollDistance = 190
// in the page markup, above the actual content
<div id="scroll-effect" style={{ height: `${logoScrollDistance}px` }} />
// on mount, start one pixel past the effect zone
window.scrollTo({ top: logoScrollDistance + 1 })
setTimeout(() => window.scrollTo({ top: logoScrollDistance + 1 }))Scroll position becomes one progress number
The scroll handler reduces everything to logoProgress: 0 when you are at or below the 190px mark, 1 when you reach the very top. Every visual is derived from that single number. The displacement scale ramps to -60, and the logo stretches vertically. Both writes go straight to the DOM, scale.baseVal on the filter and an inline transform on the wrapper, so there is no React re-render inside the scroll loop.
const logoHeight = 56
const updateScrollEffects = () => {
logoProgress = Math.max(0, logoScrollDistance - window.scrollY) / logoScrollDistance
logoDisplacement.scale.baseVal = -logoProgress * 60
logo.style.transform = `scaleY(${1 + logoProgress * (logoScrollDistance / logoHeight)})`
headerOuter.style.setProperty('--header-nav-offset', `${logoProgress * logoScrollDistance}px`)
}
window.addEventListener('scroll', updateScrollEffects)The stretch is sized to absorb the scroll
The magic constant is the ratio in the transform: logoScrollDistance / logoHeight, which is 190 over 56. At full progress the logo is scaled to about 4.4 times its height, which means the extra height it gains is exactly progress * 190 pixels: the same number of pixels the scroll has consumed. The logo grows at precisely the rate the page scrolls, so the content below it barely moves. The scroll does not feel like scrolling. It feels like pulling the logo.
Two supporting details live in CSS. The wrapper's transform-origin pins the top edge so the stretch goes downward, and on mobile the nav links ride the same progress value through a custom property so they slide out of the way instead of being swallowed by the growing wordmark. The header's bottom border is also set to transparent while inside the zone, so there is no hard line cutting through the stretch.
.header--inner > div {
transform-origin: 50% 0%;
}
@media (max-width: 767px) {
.portfolio-header .header--nav {
transform: translateY(var(--header-nav-offset, 0px));
}
}The rubber band
A stretched logo is a fun state to visit but a bad state to stay in. When the scroll gesture ends anywhere inside the effect zone, the page smooth-scrolls itself back to one pixel past the zone, and since every visual is derived from scroll position, the snap-back animates the logo home for free. scrollend covers wheel and keyboard scrolling, touchend covers mobile, and the duplicated call inside setTimeout re-asserts the target in browsers that cancel a smooth scroll when momentum scrolling is still settling.
const settlePastEffectZone = () => {
if (logoProgress > 0) {
window.scrollTo({ top: logoScrollDistance + 1, behavior: 'smooth' })
setTimeout(() => window.scrollTo({ top: logoScrollDistance + 1, behavior: 'smooth' }))
}
}
window.addEventListener('scrollend', settlePastEffectZone)
window.addEventListener('touchend', settlePastEffectZone)So the whole animation is one derived number and two cheap writes per scroll frame. There is no animation library, no timeline, and no state in React. The turbulence field itself never even animates; only the amount of displacement does, and scroll position is the only clock. That is also why it feels physical: the logo does not play an animation at you, it moves exactly as far as your finger does.