Update: Just 'scroll' events, no 'wheel' or 'mousewheel'.
CSS and JavaScript?
Here's a way: (jsfiddle).
Tested in Firefox 46 and Chrome 50.
This solution uses an empty dummy element inside a scrollable element, sized to match the height of the content-bearing element (with hidden scrollbar), and some lightweight JavaScript to relay 'scroll' events between content and dummy.
Pros:
- Uses browsers' native scrollbars and
scroll events.
- No extra libraries needed
- Doesn't weigh down the DOM
Cons:
- It's a hack. But that's what we do!
HTML
<div id="wrapper">
<div id="header"></div>
<!-- An empty dummy to carry the scrollbar -->
<div id="scroll-wrap">
<div id="scroll-dummy"></div>
</div>
<!-- Content -->
<div id="content-view">[Actual content.]</div>
</div>
CSS
body { overflow: hidden; }
#content-view {
position: absolute;
z-index: 0;
top: 0; /* Place content behind header. */
height: 100vh; /* Fill viewport. */
overflow-y: scroll; /* Hidden scrollbar. */
}
#header {
position: relative; /* Enable z-index. */
z-index: 1; /* Place header above content. */
height: 25vh; /* Top of viewport. */
}
#scroll-wrap {
position: absolute;
z-index: 1; /* Place scrollbar above content. */
top: 25vh; /* Skip header. */
height: 75vh; /* Fill remaining viewport. */
right: 0; /* Avoid non-scroll mouse events. */
overflow-y: scroll; /* Scrollbar! */
}
#scroll-dummy { width: 1px; } /* Firefox won't scroll width:0 elements! */
JavaScript
var $ = document.getElementById.bind(document),
contentView = $('content-view'), scrollDummy = $('scroll-dummy'),
scrollWrap = $('scroll-wrap'), wrapper = $('wrapper'), header=$('header');
scrollWidth = scrollWrap.offsetWidth - scrollWrap.clientWidth;
// Hide content's actual scrollbar
contentView.style.right = -scrollWidth + 'px';
// Copy height of visible content to hidden dummy
function onContentChange() {
window.requestAnimationFrame(function() {
scrollDummy.style.height = contentView.scrollHeight - header.offsetHeight + 'px';
});
}
window.addEventListener('resize', onContentChange);
onContentChange(); // Call whenever the contentView node changes!
// No event-triggering feedback loops
var fromContent = false, fromScroll = false;
// Update scrollbar scroll on content scroll
animate(contentView, 'scroll', function(e){
if (fromScroll) fromScroll = false;
else {
fromContent = true;
scrollWrap.scrollTop = contentView.scrollTop; // Scroll the scrollbar
}
});
// Update content scroll on scrollbar scroll
animate(scrollWrap, 'scroll', function(){
if (fromContent) fromContent=false;
else {
fromScroll = true;
contentView.scrollTop = scrollWrap.scrollTop; // Scroll the content
}
});
// Wrap event handler with throttled rAF
function animate(element, event, func) {
var lock = false;
element.addEventListener(event, function(e) {
if (!lock) {
lock = true;
window.requestAnimationFrame(function(){ func(e); lock = false; });
}
}, false);
}
<article>never goes under the<header>so the opacity change won't make the<article>part to show through the<header>. The native scrollbar starts from under the header for this particular reason.