From 6e785d6dbf8ded455e60fa07026e0c6eb091297d Mon Sep 17 00:00:00 2001 From: designbyalex Date: Thu, 6 Aug 2026 11:03:48 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20homepage=20hero=20scroll?= =?UTF-8?q?=20arrow=20landing=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll arrow aimed at itself: it scrolled so the button sat 10% of the viewport height from the top of the screen. That is a proxy for, not the same as, the start of the next section, so a slice of hero stayed on screen and the next section was pushed down and cut off. Because the offset was a fraction of viewport height, the landing spot also drifted with screen size. It now aims at the next block. V2ComponentWrapper marks its inner content section with data-block-content, so the target is declared rather than guessed at by walking the DOM: decorative layers (bleed image, redGlow, gridOverlay) render outside that marker and can no longer be mistaken for content, and wrapper padding is skipped by reading the content container directly. findNextBlock steps over blocks that render nothing, are hidden at the current breakpoint, or are decorative. Spacer now carries aria-hidden on whichever element it renders outermost — previously only its undecorated branch had it, so a decorated spacer or one with hideOn set could become the scroll target. Also honours prefers-reduced-motion ("instant" rather than "auto", since auto defers to the global scroll-smooth in styles.css), and moves focus to the landed section so screen readers and the keyboard follow the viewport. Verified in Chrome at 1440x900 and 390x844: the next block's content lands at the top of the viewport, where the old code stopped 261px and 255px short. Fixed #4928 Co-Authored-By: Claude Opus 5 --- components/blocks/spacer/spacer.tsx | 10 +++- components/blocks/v3/heroBox/heroBox.tsx | 10 ++-- .../layout/v2ComponentWrapper.schema.tsx | 2 +- components/layout/v2ComponentWrapper.tsx | 9 +++- lib/scrollToBlock.ts | 52 +++++++++++++++++++ 5 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 lib/scrollToBlock.ts diff --git a/components/blocks/spacer/spacer.tsx b/components/blocks/spacer/spacer.tsx index d50974152c..c6718f4458 100644 --- a/components/blocks/spacer/spacer.tsx +++ b/components/blocks/spacer/spacer.tsx @@ -17,7 +17,7 @@ export function Spacer({ data }: { data: Consultingv2BlocksSpacer }) { ); const spacer = isDecorated ? ( - +
); - return hideClasses ?
{spacer}
: spacer; + return hideClasses ? ( + + ) : ( + spacer + ); } diff --git a/components/blocks/v3/heroBox/heroBox.tsx b/components/blocks/v3/heroBox/heroBox.tsx index bf5b3bca0f..d47e045075 100644 --- a/components/blocks/v3/heroBox/heroBox.tsx +++ b/components/blocks/v3/heroBox/heroBox.tsx @@ -4,6 +4,7 @@ import ButtonRow from "@/components/blocksSubtemplates/buttonRow"; import { backgroundOptions } from "@/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions"; import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; import { Container } from "@/components/util/container"; +import { findNextBlock, scrollToBlockContent } from "@/lib/scrollToBlock"; import { cn } from "@/lib/utils"; import Image from "next/image"; import { useState, type CSSProperties } from "react"; @@ -59,13 +60,8 @@ export const V3HeroBox = ({ data, priority = false }) => { type="button" aria-label="Scroll to content" onClick={(e) => { - // Scroll so the arrow ends up 10% of the viewport height down from - // the top of the screen, pulling the hero up out of the way. - const { top } = e.currentTarget.getBoundingClientRect(); - window.scrollTo({ - top: window.scrollY + top - window.innerHeight * 0.1, - behavior: "smooth", - }); + const nextBlock = findNextBlock(e.currentTarget); + if (nextBlock) scrollToBlockContent(nextBlock); }} className="pointer-events-auto flex size-11 items-center justify-center rounded-full border border-black/70 text-black transition-colors hover:bg-black hover:text-white dark:border-white/80 dark:text-white dark:hover:bg-white dark:hover:text-black" > diff --git a/components/layout/v2ComponentWrapper.schema.tsx b/components/layout/v2ComponentWrapper.schema.tsx index f4c2d5762e..04b2412cee 100644 --- a/components/layout/v2ComponentWrapper.schema.tsx +++ b/components/layout/v2ComponentWrapper.schema.tsx @@ -72,7 +72,7 @@ export const anchorIdSchema: TinaField = { }, }, description: - "Optional id for in-page links. Set this on a section, then point a button's link at # to jump here (the offset for the sticky header is handled automatically).", + "Optional id for in-page links. Set this on a section, then point a button's link at # to jump here (a little breathing room above the section is added automatically).", }; // Background + anchor fields every V2 block shares — spread once via `...wrapperBaseFields`. diff --git a/components/layout/v2ComponentWrapper.tsx b/components/layout/v2ComponentWrapper.tsx index e7e6280e48..5dad0093a6 100644 --- a/components/layout/v2ComponentWrapper.tsx +++ b/components/layout/v2ComponentWrapper.tsx @@ -23,11 +23,13 @@ const V2ComponentWrapper = ({ children, fadeInMargin = "-100px", className, + ariaHidden, }: { data: BackgroundData; children: React.ReactNode; fadeInMargin?: UseInViewOptions["margin"]; className?: string; + ariaHidden?: boolean; }) => { //Bleed effect setup const bleed = useRef(null); @@ -58,12 +60,13 @@ const V2ComponentWrapper = ({ return (
{ return value.reference === data.background?.backgroundColour; })?.classes, "relative w-full overflow-visible", - // Offset in-page anchor scrolling so the target clears the sticky header + // Breathing room above a section jumped to by an in-page anchor link. data.anchorId && "scroll-mt-24", className )} @@ -110,8 +113,12 @@ const V2ComponentWrapper = ({ className="pointer-events-none absolute inset-0 z-25 bg-dot-grid bg-dots" /> )} + {/* data-block-content marks where this block's content starts, past the + wrapper's padding and the decorative layers above. lib/scrollToBlock + targets it. */}
` by +// blocks-renderer.tsx; that wrapper is the block boundary here. +const BLOCK_WRAPPER = "[data-tinafield]"; + +export function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** The next block after `fromEl`'s own that renders a visible box. */ +export function findNextBlock(fromEl: Element): Element | null { + let sibling = fromEl.closest(BLOCK_WRAPPER)?.nextElementSibling ?? null; + while (sibling) { + const root = sibling.firstElementChild; + if ( + root && + root.getClientRects().length > 0 && + root.getAttribute("aria-hidden") !== "true" + ) { + return root; + } + sibling = sibling.nextElementSibling; + } + return null; +} + +function contentSection(blockRoot: Element): Element { + return blockRoot.querySelector(":scope > [data-block-content]") ?? blockRoot; +} + +/** Document offset where a block's content starts, past its container padding. */ +export function getBlockContentTop(blockRoot: Element): number { + const content = contentSection(blockRoot); + const container = content.firstElementChild; + const laidOut = !!container && container.getClientRects().length > 0; + const el = laidOut ? container : content; + const padding = laidOut + ? parseFloat(getComputedStyle(container).paddingTop) || 0 + : 0; + return window.scrollY + el.getBoundingClientRect().top + padding; +} + +export function scrollToBlockContent(blockRoot: Element): void { + window.scrollTo({ + top: getBlockContentTop(blockRoot), + // "instant" not "auto" — auto defers to the global scroll-smooth CSS. + behavior: prefersReducedMotion() ? "instant" : "smooth", + }); + // Move the reading position too, so screen readers and the keyboard follow. + const target = contentSection(blockRoot) as HTMLElement; + if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1"); + target.focus({ preventScroll: true }); +}