If there are elements deep into the viewport near the top or bottom they will end up obscured by the edge of the viewport when scrolling. This may be undesirable in some cases. Implement a clamping of the allowed range of the perspective origin to give better control over the perspective and what it obscures.
130 lines
4.8 KiB
JavaScript
130 lines
4.8 KiB
JavaScript
let heightOffset = 0;
|
|
let lastHeight = window.innerHeight;
|
|
function getWindowHeight(event) {
|
|
|
|
/*
|
|
When scrolling such that the browser toolbar is pushed out of view or
|
|
pulled into view on a mobile device the height of the viewport changes.
|
|
This causes the perspective to shift noticeably when you let go of your
|
|
finger after scrolling. To avoid this, I store the last change of the
|
|
window height if this was previously 0 and apply that to the calculated
|
|
height of the window when the size changes.
|
|
|
|
This causes the heightOffset to toggle between 0 and the value that
|
|
negates the perspective shift when you switch between having the browser
|
|
toolbar visible and not visible on mobile, but stay as 0 in most other
|
|
situations involving window resize.
|
|
*/
|
|
if (event?.type === 'resize') {
|
|
if (heightOffset === 0 && lastHeight !== 0) {
|
|
const newOffset = lastHeight - window.innerHeight;
|
|
// Expect the browser toolbar resize to be between 20 and 100 pixels.
|
|
if (Math.abs(newOffset) > 20 && Math.abs(newOffset) < 100) {
|
|
heightOffset = newOffset;
|
|
} else {
|
|
heightOffset = 0;
|
|
}
|
|
} else {
|
|
heightOffset = 0;
|
|
}
|
|
lastHeight = window.innerHeight;
|
|
}
|
|
return window.innerHeight + heightOffset;
|
|
}
|
|
|
|
/**
|
|
Returns x rescaled such that the values in the range a-b are mapped to the range c-d.
|
|
*/
|
|
function rescaleRange(x, a, b, c, d){
|
|
return c + (x - a) * (d - c) / (b - a);
|
|
}
|
|
|
|
/**
|
|
If value is not undefined return it parsed as a float, otherwise returns undefined.
|
|
*/
|
|
function maybeFloat(value) {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
return Number.parseFloat(value);
|
|
}
|
|
|
|
function setViewportOffset(viewport, windowHeight) {
|
|
|
|
/*
|
|
Ignoring updates if the the bounding height of the viewport element is
|
|
not inside of the visible client area on the page to optimise for when
|
|
it not visible due to being off screen.
|
|
*/
|
|
const viewportRect = viewport.getBoundingClientRect();
|
|
if (viewportRect.top > window.innerHeight || viewportRect.bottom < 0) {
|
|
return;
|
|
}
|
|
|
|
/*
|
|
Calculate the distance from the top of the viewport to the center of the
|
|
screen. This is an accurate rendering of perspective shift as you
|
|
scroll.
|
|
|
|
If the the bounds of a viewport element is close to the top or bottom
|
|
of the page the perspective origin is adjusted so that it is still
|
|
contained inside of the bounds of the element to not look out of place.
|
|
endOffset controls how much into the bounds the perspective origin should be
|
|
pushed when the screen in scrolled all the way to the top/bottom.
|
|
*/
|
|
const documentRect = document.documentElement.getBoundingClientRect();
|
|
const endOffset = viewportRect.height * 0.25;
|
|
let origin = Math.min(
|
|
(viewportRect.bottom - documentRect.top) - endOffset,
|
|
Math.max(
|
|
windowHeight / 2,
|
|
windowHeight - (documentRect.bottom - viewportRect.top) + endOffset,
|
|
)
|
|
);
|
|
|
|
/*
|
|
In some cases it's undesirable to let the perspective origin move too far
|
|
away in one or both directions as it causes elements inside the 3d view to
|
|
become obscured by the top/bottom of the viewport. To aid with this
|
|
optionally clamp how far the perspective origin can move relative to the top
|
|
and bottom.
|
|
|
|
This is done by calculating how far the origin can go un-clamped while the
|
|
viewport is visible on the screen and then rescaling the range of the
|
|
perspective origin from it's un-clamped range to its clamped range.
|
|
*/
|
|
const clampTop = maybeFloat(viewport.dataset.clampTop);
|
|
const clampBottom = maybeFloat(viewport.dataset.clampBottom);
|
|
if (clampBottom !== undefined || clampTop !== undefined) {
|
|
const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
const topOrigin = Math.min(
|
|
viewportRect.bottom - endOffset,
|
|
Math.max(viewportRect.top, documentRect.top + windowHeight) - windowHeight / 2,
|
|
)
|
|
const topClampPosition = rootFontSize * (clampTop ?? -Infinity) + viewportRect.top;
|
|
const topLimit = Math.max(topOrigin, topClampPosition);
|
|
|
|
const bottomOrigin = Math.max(
|
|
viewportRect.top + endOffset,
|
|
Math.min(viewportRect.bottom, documentRect.bottom - windowHeight) + windowHeight / 2,
|
|
)
|
|
const bottomClampPosition = rootFontSize * (clampBottom ?? +Infinity) + viewportRect.bottom;
|
|
const bottomLimit = Math.min(bottomOrigin, bottomClampPosition);
|
|
|
|
origin = rescaleRange(origin, topOrigin, bottomOrigin, topLimit, bottomLimit);
|
|
}
|
|
|
|
const yOffset = origin - viewportRect.top;
|
|
viewport.style.setProperty('perspective-origin', `50% ${yOffset}px`);
|
|
}
|
|
|
|
const viewports = document.querySelectorAll(".viewport");
|
|
function updateViewports(event) {
|
|
const windowHeight = getWindowHeight(event);
|
|
for (const viewport of viewports) {
|
|
setViewportOffset(viewport, windowHeight);
|
|
}
|
|
}
|
|
window.addEventListener("scroll", updateViewports, { passive: true });
|
|
window.addEventListener("resize", updateViewports, { passive: true });
|
|
updateViewports();
|