Fix page cover image jumping on load (#4648)

This commit is contained in:
Peter White
2026-09-29 18:11:03 +02:00
committed by GitHub
parent d8dc59b1a9
commit 0911abc55c
3 changed files with 46 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
@@ -29,18 +29,21 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -52,7 +55,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -73,7 +76,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}%`,
objectPosition: `50% ${objectPositionY}`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -1,6 +1,5 @@
'use client';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
import { useLayoutEffect, useState } from 'react';
interface ImageSize {
width: number;
@@ -22,19 +21,18 @@ interface Images {
}
/**
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
*/
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -68,42 +66,31 @@ export function useCoverPosition(imgs: Images, y: number) {
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
containerRef,
objectPositionY,
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}