From 0911abc55c9ce6eb63abdacc63afdcd1fdb06f35 Mon Sep 17 00:00:00 2001
From: Peter White <1788320+peterwhite@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:11:03 +0200
Subject: [PATCH] Fix page cover image jumping on load (#4648)
---
.changeset/fix-cover-image-jump.md | 5 ++
.../components/PageBody/PageCoverImage.tsx | 13 +--
.../components/PageBody/useCoverPosition.ts | 79 ++++++++-----------
3 files changed, 46 insertions(+), 51 deletions(-)
create mode 100644 .changeset/fix-cover-image-jump.md
diff --git a/.changeset/fix-cover-image-jump.md b/.changeset/fix-cover-image-jump.md
new file mode 100644
index 000000000..d5d255e56
--- /dev/null
+++ b/.changeset/fix-cover-image-jump.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Fix page cover image jumping on load
diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx
index 247356e20..bb679254c 100644
--- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx
+++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx
@@ -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 (
-
+
);
}
return (
-
+

(null);
+export function useCoverPosition(
+ imgs: Images,
+ y: number,
+ container: { height: number | undefined; aspectRatio: ImageSize }
+) {
const [loadedDimensions, setLoadedDimensions] = useState
(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}))`;
+}