From ee773e830d75a897a9440a1095c85f68be925974 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 12:12:56 +0200 Subject: [PATCH 1/2] Fix sticky position in split appearance Sticky content elements did not stay pinned in sections with split appearance. The rule that centers side boxes inside their column relied on relative positioning, which took precedence over the sticky position. Let boxes derive their margins from custom properties, following how they already derive their max width, so that shifting a box and undoing the group padding no longer compete for the same property. --- .../package/src/frontend/layouts/TwoColumn.module.css | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css index 3fca9a53b7..ecbc3cd7e9 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css @@ -22,6 +22,8 @@ .box { max-width: var(--content-max-width); + margin-left: calc(var(--side-offset-left, 0px) - var(--margin-to-undo, 0px)); + margin-right: calc(var(--side-offset-right, 0px) - var(--margin-to-undo, 0px)); } .inline { @@ -103,8 +105,6 @@ --f: calc(1 / (1 - 2 * var(--content-margin-fraction))); --margin-to-undo: var(--theme-content-margin, calc(100% * var(--content-margin-fraction) * var(--f))); - margin-left: calc(-1 * var(--margin-to-undo)); - margin-right: calc(-1 * var(--margin-to-undo)); max-width: calc(var(--margin-to-undo) * 2 + var(--content-max-width)); } @@ -122,15 +122,14 @@ } .constrainContentWidth .side { - position: relative; --side-offset: calc((min(var(--layout-inline-content-max-width), var(--constrained-content-max-width, 9999px)) - var(--content-max-width)) / 2); - right: var(--side-offset); + --side-offset-right: var(--side-offset); } .constrainContentWidth.right .side { - right: auto; - left: var(--side-offset); + --side-offset-right: 0px; + --side-offset-left: var(--side-offset); } .sticky { From 267b345bbb8f6078a54d176e4fe645bf27b96b7a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 15:20:00 +0200 Subject: [PATCH 2/2] Keep sticky boxes inside the viewport Sticky content elements were pinned a third down the viewport no matter how tall they were, cutting off the lower part of tall elements and of anything sticking in a short viewport. Two sticky elements sharing a box could end up not fitting at all while the space above them stayed empty. The offset now shrinks towards centering the box as it grows and reaches the top edge once the box is taller than the viewport. --- .../package/spec/frontend/Layout-spec.js | 40 +++++++++++++++++++ .../package/src/frontend/layouts/TwoColumn.js | 26 +++++++++++- .../src/frontend/layouts/TwoColumn.module.css | 15 ++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/Layout-spec.js b/entry_types/scrolled/package/spec/frontend/Layout-spec.js index 4f3f0a1b08..097126b490 100644 --- a/entry_types/scrolled/package/spec/frontend/Layout-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Layout-spec.js @@ -10,6 +10,7 @@ import twoColumnStyles from 'frontend/layouts/TwoColumn.module.css'; import {widthName} from 'frontend/layouts/widths'; import {renderInEntry} from 'testHelpers'; +import {fakeResizeObserver} from 'support/fakeResizeObserver'; describe('Layout', () => { describe('placeholder', () => { @@ -1604,6 +1605,45 @@ describe('Layout', () => { }); }); + describe('sticky box height in two column variant', () => { + beforeAll(() => { + frontend.contentElementTypes.register('probe', { + component: function Probe() { + return
; + } + }); + }); + + it('exposes observed height of sticky box as custom property', () => { + fakeResizeObserver.contentRect = {width: 300, height: 600}; + const items = [ + {id: 2, type: 'probe', position: 'sticky'} + ]; + const {getByTestId} = renderInEntry( + + {children => children} + + ); + + const box = findParentWithClass(getByTestId('probe'), twoColumnStyles.box); + + expect(box.style.getPropertyValue('--sticky-box-height')).toEqual('600px'); + }); + + it('does not observe inline boxes', () => { + const items = [ + {id: 2, type: 'probe', position: 'inline'} + ]; + renderInEntry( + + {children => children} + + ); + + expect(fakeResizeObserver.observe).not.toHaveBeenCalled(); + }); + }); + function findParentWithClass(element, className) { let currentElement = element; diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index d3337b56be..bca5d19b2c 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -1,4 +1,4 @@ -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import classNames from 'classnames'; import {api} from '../api'; @@ -97,6 +97,8 @@ function renderItemGroup(props, box, key) { function Box({box, children}) { const ref = useRef(); + useStickyBoxHeight(ref, box.position === 'sticky'); + return (
{ + if (!sticky || typeof ResizeObserver === 'undefined') { + return; + } + + const box = ref.current; + + const observer = new ResizeObserver(entries => + box.style.setProperty('--sticky-box-height', + `${entries[entries.length - 1].contentRect.height}px`) + ); + + observer.observe(box); + + return () => { + observer.disconnect(); + box.style.removeProperty('--sticky-box-height'); + }; + }, [ref, sticky]); +} + // Sticky boxes stay pinned while the rest of their group scrolls past. // The group therefore is the subject that drives view timelines of // content elements inside the box. diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css index ecbc3cd7e9..59fae58370 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.module.css @@ -117,7 +117,6 @@ --content-max-width: min(var(--content-width), var(--two-column-sticky-content-max-width, 600px)); float: right; clear: both; - top: 33%; width: var(--content-width); } @@ -132,9 +131,23 @@ --side-offset-left: var(--side-offset); } +/* + cssnano strips units from zero lengths, turning `0px` into `0`. That + would invalidate `initial-value` as well as any zero inside a math + function, so the property uses the universal syntax and the box + height is clamped to the viewport height instead of the result. +*/ +@property --sticky-box-height { + syntax: '*'; + inherits: false; +} + .sticky { composes: side; position: sticky; + --sticky-box-height: 0px; + top: min(33%, + (100 * var(--vh) - min(var(--sticky-box-height), 100 * var(--vh))) / 2); } .right .side {