From 6b05c0a4de6f8b5bbd54f968790afcd1f7fb123d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:20:12 +0100 Subject: [PATCH] Address sticky Header review feedback Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 1 - .../src/components/Header/Header.stories.tsx | 6 ++-- packages/ui/src/components/Header/Header.tsx | 32 ++++++++++++++++++- packages/ui/src/components/Header/types.ts | 4 +++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index d49a512536..85f59f178f 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1743,7 +1743,6 @@ export interface HeaderOwnProps { description?: string; // (undocumented) metadata?: HeaderMetadataItem[]; - // (undocumented) sticky?: boolean; // (undocumented) tabs?: HeaderNavTabItem[]; diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index b77a4c16c1..9cc715e471 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -24,7 +24,7 @@ import { MemoryRouter } from 'react-router-dom'; import { BUIProvider } from '../../provider'; import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../'; import { RiMore2Line } from '@remixicon/react'; -import { Container } from '../Container/Container'; +import { Container } from '../Container'; const meta = preview.meta({ title: 'Backstage UI/Header', @@ -415,8 +415,8 @@ export const NonSticky = meta.story({ {Array.from({ length: 60 }, (_, i) => (

- Scroll down to see the title bar stick to the top while the tags, - description, and metadata scroll away. Line {i + 1}. + Scroll down to see the entire header scroll away with the rest of + the page content. Line {i + 1}.

))}
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index ad70508250..6e36d627f8 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -43,6 +43,16 @@ const getScrollParent = (element: HTMLElement | null): Element | null => { return null; }; +const isStickySentinelOutOfView = ( + sentinel: HTMLElement, + root: Element | null, +) => { + const sentinelRect = sentinel.getBoundingClientRect(); + const rootTop = root ? root.getBoundingClientRect().top : 0; + + return sentinelRect.bottom <= rootTop; +}; + /** * Parses inline Markdown links in a string and returns an array of React nodes. * URLs are sanitized via `@braintree/sanitize-url`; unsafe URLs are rendered as @@ -103,11 +113,31 @@ export const Header = (props: HeaderProps) => { return; } + const root = getScrollParent(sentinel); + + if (typeof IntersectionObserver === 'undefined') { + const updateStuckState = () => { + setIsStuck(isStickySentinelOutOfView(sentinel, root)); + }; + const scrollTarget = root ?? window; + + updateStuckState(); + scrollTarget.addEventListener('scroll', updateStuckState, { + passive: true, + }); + window.addEventListener('resize', updateStuckState); + + return () => { + scrollTarget.removeEventListener('scroll', updateStuckState); + window.removeEventListener('resize', updateStuckState); + }; + } + const observer = new IntersectionObserver( ([entry]) => { setIsStuck(!entry.isIntersecting); }, - { root: getScrollParent(sentinel), threshold: 0 }, + { root, threshold: 0 }, ); observer.observe(sentinel); diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index d126f40ce3..77fbe937c3 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -116,6 +116,10 @@ export interface HeaderOwnProps { tags?: HeaderTag[]; metadata?: HeaderMetadataItem[]; className?: string; + /** + * Makes the title-and-actions row stick to the top of its nearest scroll + * container while the rest of the header content scrolls away. + */ sticky?: boolean; }