jQuery / Create a Menu That Stays Fixed at the Top
Compare document scroll position with a menu’s original position, then enable a class using position:fixed when that boundary is reached. Remove the class above the boundary. Preserve equal-height space at the original location so removing the menu from normal flow does not pull content upward.
Choose a stable boundary and placeholder
CSS position:fixed leaves normal document flow and usually positions relative to the viewport. top:0 means its upper edge. The original location is a document coordinate, so use matching coordinates for the comparison.
Do not repeatedly treat the fixed menu’s own position as its original position. The example measures #menu-slot, which remains in the document. Its viewport top plus window.scrollY gives the document threshold.
A fixed menu may no longer inherit its parent’s width in the same way. Set its left and width from the slot, then retain its border-inclusive height in that slot. The example uses no menu margin and border-box sizing to align the boundaries.
Complete scroll and resize example
Save everything as UTF-8 fixed-menu-demo.html and open it in a browser. Internet loads the official jQuery file 4.0.0. No server or installation is needed. Boundary switching, content position, resizing and anchors were checked in Windows Edge with 3.7.1 and 4.0.0.
The header is 180px high, so the initial threshold is document position 180. Bottom controls and status are exercise tools; retain only relevant functionality on your site.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A menu fixed after its original position</title>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<style>
:root { --menu-height: 60px; }
html { scroll-behavior: auto; }
body { margin: 0; font: 16px/1.5 sans-serif; }
header { height: 180px; display: grid; place-items: center; background: #e7f2f2; }
.wrap { width: 100%; max-width: 800px; padding: 0 20px; margin: 0 auto; box-sizing: border-box; }
#menu { box-sizing: border-box; width: 100%; display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; border: 2px solid teal; background: #fff; }
#menu a { padding: 4px; color: #075c55; }
#menu.is-fixed { position: fixed; top: 0; z-index: 20; box-shadow: 0 3px 8px #0003; }
main section { min-height: 650px; padding-top: 24px; }
main h2 { margin: 0; scroll-margin-top: calc(var(--menu-height) + 16px); }
#controls { position: fixed; right: 8px; bottom: 8px; z-index: 30; background: #fff; padding: 4px; }
button { font: inherit; margin: 2px; }
#result { position: fixed; left: 8px; bottom: 70px; background: #fff; white-space: pre-wrap; padding: 6px; z-index: 30; }
</style>
</head>
<body>
<header>Header before the menu</header>
<div class="wrap"><div id="menu-slot">
<nav id="menu" aria-label="Sections"><a href="#alpha">Alpha section</a><a href="#beta">Beta section</a><a href="#gamma">Gamma section</a></nav>
</div></div>
<main class="wrap">
<section><h2 id="alpha">Alpha</h2><p>First section content.</p></section>
<section><h2 id="beta">Beta</h2><p>Second section content.</p></section>
<section><h2 id="gamma">Gamma</h2><p>Third section content.</p></section>
</main>
<div id="controls"><button type="button" id="down">Go to 300px</button><button type="button" id="reset">Back to 0px</button></div>
<pre id="result" aria-live="polite"></pre>
<script>
$(function () {
const $menu = $("#menu"), $slot = $("#menu-slot");
let scheduled = false;
function update() {
scheduled = false;
const rect = $slot[0].getBoundingClientRect();
const threshold = rect.top + window.scrollY;
const fixed = $(window).scrollTop() >= threshold;
$menu.toggleClass("is-fixed", fixed).css({
left: fixed ? rect.left + "px" : "",
width: fixed ? rect.width + "px" : ""
});
const height = $menu.outerHeight();
$slot.css("height", height + "px");
document.documentElement.style.setProperty("--menu-height", height + "px");
$("#result").text(`menu=${fixed ? "fixed" : "flow"}\nthreshold=${Math.round(threshold)}\nscrollY=${Math.round(window.scrollY)}`);
}
function schedule() {
if (!scheduled) { scheduled = true; window.requestAnimationFrame(update); }
}
$(window).on("scroll.fixedMenuLesson resize.fixedMenuLesson load.fixedMenuLesson", schedule);
$("#down").on("click", function () { window.scrollTo(0, 300); schedule(); });
$("#reset").on("click", function () { window.scrollTo(0, 0); schedule(); });
update();
});
</script>
</body>
</html>
Initial output at the page top. flow means normal placement.
menu=flow
threshold=180
scrollY=0
- Scroll slowly. At 179px the menu remains in flow; from 180px it stays at viewport top zero.
- Go to 300px gives menu=fixed, threshold=180 and scrollY=300. Content should not jump upward by the menu height.
- Resize wider and narrower while fixed. The menu must match the content container’s left and width; wrapped links update placeholder height.
- Beta section follows the real #beta anchor, keeping its heading visible below the menu. The links remain focusable and use normal keyboard activation.
- Back to 0px restores flow and clears fixed left and width. It resets the scroll position, not an existing URL hash.
Update timing and anchor clearance
Compare $(window).scrollTop() with the slot threshold. .toggleClass("is-fixed", fixed) explicitly applies that Boolean state. Calling the one-argument toggle on every scroll would alternate classes according to event count instead.
scroll, resize and load schedule update() through requestAnimationFrame() before the next repaint. scheduled avoids multiple outstanding callbacks. This does not guarantee a millisecond interval or a particular frame rate; keep the measurements and changes lightweight.
Width is set before height is measured, allowing wrapped navigation to update the slot. Initial update() also handles restored scroll positions. If content above the menu grows later or fonts change dimensions, connect schedule() after that work. The example does not automatically observe every DOM change.
--menu-height records menu height; heading scroll-margin-top adds 16px. Native anchor targets therefore remain below the menu. With another fixed header, design both top and target clearance for its height. Keep real href values and avoid unnecessarily preventing navigation.
CSS sticky and deployment checks
For simply sticking at the top after reaching it, position:sticky plus top can work without JavaScript. It retains document space and is limited by its scroll container and containing block. Inspect short parents and overflow ancestors rather than treating sticky as identical to fixed.
This example handles whole-document scrolling, not an inner scrolling panel. Transformed ancestors can change the reference for fixed positioning. Inspect the actual ancestry instead of overriding global position or overflow rules.
A high z-index does not resolve every modal, dropdown or stacking context. Check that mobile content remains visible, keyboard focus is visible and zoom still permits access to links. Remove or reposition exercise overlays on real pages.
Teardown requires more than removing the event namespace: restore the fixed class, inline left/width, placeholder height and custom property within the feature’s ownership. If initialized repeatedly in an app, manage lifecycle to prevent duplicate handlers.




