From 5351d8ac639e9dbafaf9aea8dc7bcc88c39fc6d0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 09:04:51 +0100 Subject: [PATCH 01/15] Add sticky Header support Signed-off-by: Charles de Dreuille --- .changeset/header-sticky-prop.md | 5 + .storybook/preview.tsx | 49 ++--- .storybook/themes/spotify.css | 4 - .../components/Container/Container.module.css | 2 + .../src/components/Header/Header.module.css | 87 +++++++- .../src/components/Header/Header.stories.tsx | 90 +++++++++ packages/ui/src/components/Header/Header.tsx | 188 ++++++++++++------ .../ui/src/components/Header/definition.ts | 6 + packages/ui/src/components/Header/types.ts | 1 + 9 files changed, 333 insertions(+), 99 deletions(-) create mode 100644 .changeset/header-sticky-prop.md diff --git a/.changeset/header-sticky-prop.md b/.changeset/header-sticky-prop.md new file mode 100644 index 0000000000..283c235d43 --- /dev/null +++ b/.changeset/header-sticky-prop.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added a `sticky` prop to the `Header` component. When `true`, the title-and-actions bar stays fixed to the top of its scroll container while the rest of the header (tags, description, metadata) scrolls away. The sticky bar background color automatically matches the container surface using the bg-consumer system. diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 916c48951d..eb0b111bc9 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -50,26 +50,11 @@ export default definePreview({ dynamicTitle: true, }, }, - background: { - name: 'Background', - description: 'Global background for components', - defaultValue: 'app', - toolbar: { - icon: 'contrast', - items: [ - { value: 'app', title: 'App Background' }, - { value: 'neutral-1', title: 'Neutral 1 Background' }, - { value: 'neutral-2', title: 'Neutral 2 Background' }, - { value: 'neutral-3', title: 'Neutral 3 Background' }, - ], - }, - }, }, initialGlobals: { themeMode: 'light', themeName: 'backstage', - background: 'app', }, parameters: { @@ -143,7 +128,6 @@ export default definePreview({ globals.themeMode === 'light' ? themes.light : themes.dark; const selectedThemeMode = globals.themeMode || 'light'; const selectedThemeName = globals.themeName || 'backstage'; - const selectedBackground = globals.background || 'app'; const isFullscreen = context.parameters.layout === 'fullscreen'; useEffect(() => { @@ -155,15 +139,13 @@ export default definePreview({ document.body.removeAttribute('data-theme-mode'); document.body.removeAttribute('data-theme-name'); }; - }, [selectedTheme, selectedThemeName]); + }, [selectedThemeMode, selectedThemeName]); useEffect(() => { appThemeApi.setActiveThemeId(selectedThemeMode); }, [selectedThemeMode]); document.body.style.backgroundColor = 'var(--bui-bg-app)'; - document.body.style.padding = - isFullscreen && selectedBackground !== 'app' ? '1rem' : ''; const docsStoryElements = document.getElementsByClassName('docs-story'); Array.from(docsStoryElements).forEach(element => { (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; @@ -174,18 +156,23 @@ export default definePreview({ {/* @ts-ignore */} - {Array.from({ - length: - selectedBackground === 'app' - ? 0 - : parseInt(selectedBackground.split('-')[1], 10), - }).reduce( - children => ( - - {children} - - ), - , + {selectedThemeName === 'spotify' ? ( + + + + ) : ( + )} diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index 30aa0274d0..d8f06c68c0 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -190,10 +190,6 @@ .bui-Tag { border-radius: var(--bui-radius-full); } - - .bui-Container { - padding-inline: 0; - } } [data-theme-mode='light'][data-theme-name='spotify'] { diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index 1ebf52ccf3..9381126ee4 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -18,6 +18,8 @@ @layer components { .bui-Container { + flex: 1; + width: 100%; max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 2e3ca28d3b..08626268f7 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -18,16 +18,87 @@ @layer components { .bui-Header { + display: flex; + flex-direction: column; + padding-top: var(--bui-space-2); + width: 100%; + padding-inline: var(--bui-space-5); + } + + .bui-Header[data-sticky] { + display: contents; + } + + .bui-HeaderAfterSticky { display: flex; flex-direction: column; gap: var(--bui-space-3); - margin-top: var(--bui-space-6); + } + + .bui-Header[data-sticky] .bui-HeaderBeforeSticky, + .bui-Header[data-sticky] .bui-HeaderStickySentinel, + .bui-Header[data-sticky] .bui-HeaderContent, + .bui-Header[data-sticky] .bui-HeaderAfterSticky { + width: 100%; + padding-inline: var(--bui-space-5); + box-sizing: border-box; + } + + .bui-HeaderBeforeSticky { + padding-top: var(--bui-space-4); + } + + .bui-Header[data-sticky] .bui-HeaderBeforeSticky { + padding-top: var(--bui-space-6); + } + + .bui-HeaderStickySentinel { + height: 1px; + margin-bottom: -1px; + pointer-events: none; } .bui-HeaderContent { display: flex; flex-direction: row; justify-content: space-between; + gap: var(--bui-space-3); + padding-block: var(--bui-space-3); + } + + .bui-HeaderContent[data-sticky] { + position: sticky; + top: 0; + z-index: 10; + background-color: var(--bui-bg-app); + + .bui-Header[data-on-bg='neutral-1'] & { + background-color: var(--bui-bg-neutral-1); + } + + .bui-Header[data-on-bg='neutral-2'] & { + background-color: var(--bui-bg-neutral-2); + } + + .bui-Header[data-on-bg='neutral-3'] & { + background-color: var(--bui-bg-neutral-3); + } + } + + .bui-HeaderContent[data-sticky]::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 1px; + content: ''; + background-color: var(--bui-border-1); + opacity: 0; + transition: opacity 200ms ease; + } + + .bui-HeaderContent[data-stuck]::after { + opacity: 1; } .bui-HeaderTabsWrapper { @@ -39,6 +110,7 @@ flex-direction: row; align-items: center; gap: var(--bui-space-2); + flex-shrink: 0; } .bui-HeaderBreadcrumbs { @@ -46,6 +118,19 @@ flex-direction: row; align-items: center; gap: var(--bui-space-2); + min-width: 0; + } + + .bui-HeaderTitle { + margin: 0; + padding: 0; + overflow: hidden; + font-family: var(--bui-font-regular); + font-size: var(--bui-font-size-6); + font-weight: var(--bui-font-weight-bold); + line-height: 140%; + text-overflow: ellipsis; + white-space: nowrap; } .bui-HeaderTags { diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 59e5cf254d..09dadce9f4 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -24,6 +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'; const meta = preview.meta({ title: 'Backstage UI/Header', @@ -393,3 +394,92 @@ export const WithExplicitActiveTab = meta.story({ activeTabId: 'campaigns', }, }); + +export const NonSticky = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {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}. +

+ ))} +
+ + ), +}); + +export const Sticky = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {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}. +

+ ))} +
+ + ), +}); + +export const StickyWithLongTitle = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the long title bar stick to the top while the + tags, description, and metadata scroll away. Line {i + 1}. +

+ ))} +
+ + ), +}); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 3f17e35732..ae992f4b41 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -21,10 +21,25 @@ import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { sanitizeUrl } from '@braintree/sanitize-url'; -import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; -import { Fragment, useMemo } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; + +const getScrollParent = (element: HTMLElement | null): Element | null => { + let parent = element?.parentElement; + + while (parent) { + const { overflowY } = window.getComputedStyle(parent); + + if (/(auto|scroll|overlay)/.test(overflowY)) { + return parent; + } + + parent = parent.parentElement; + } + + return null; +}; /** * Parses inline Markdown links in a string and returns an array of React nodes. @@ -52,7 +67,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { * @public */ export const Header = (props: HeaderProps) => { - const { ownProps } = useDefinition(HeaderDefinition, props); + const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props); const { classes, title, @@ -63,41 +78,86 @@ export const Header = (props: HeaderProps) => { description, tags, metadata, + sticky, } = ownProps; const descriptionNodes = useMemo( () => (description ? renderInlineMarkdown(description) : null), [description], ); + const stickySentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + if (!sticky) { + setIsStuck(false); + return; + } + + const sentinel = stickySentinelRef.current; + if (!sentinel) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting); + }, + { root: getScrollParent(sentinel), threshold: 0 }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [sticky]); return ( - +
{tags && tags.length > 0 && ( -
    - {tags.map((tag, i) => ( -
  • - {tag.href ? ( - - {tag.label} - - ) : ( - - {tag.label} - - )} -
  • - ))} -
+
+
    + {tags.map((tag, i) => ( +
  • + {tag.href ? ( + + {tag.label} + + ) : ( + + {tag.label} + + )} +
  • + ))} +
+
)} -
+ {sticky && ( +
); }; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index c0420dade5..ace08f5195 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -24,10 +24,15 @@ import styles from './Header.module.css'; */ export const HeaderDefinition = defineComponent()({ styles, + bg: 'consumer', classNames: { root: 'bui-Header', + beforeSticky: 'bui-HeaderBeforeSticky', + stickySentinel: 'bui-HeaderStickySentinel', content: 'bui-HeaderContent', + afterSticky: 'bui-HeaderAfterSticky', breadcrumbs: 'bui-HeaderBreadcrumbs', + title: 'bui-HeaderTitle', tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', tags: 'bui-HeaderTags', @@ -46,6 +51,7 @@ export const HeaderDefinition = defineComponent()({ tags: {}, metadata: {}, className: {}, + sticky: {}, }, }); diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index bc9ce8985b..d126f40ce3 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -116,6 +116,7 @@ export interface HeaderOwnProps { tags?: HeaderTag[]; metadata?: HeaderMetadataItem[]; className?: string; + sticky?: boolean; } /** From eb1fabae6ba5fd843ceddd7dbf72dc2b5347a704 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 09:26:12 +0100 Subject: [PATCH 02/15] Refine sticky Header title transition Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 26 +++++++ .../src/components/Header/Header.module.css | 77 +++++++++++++++++-- .../src/components/Header/Header.stories.tsx | 35 +++++++++ packages/ui/src/components/Header/Header.tsx | 68 +++++++++++----- .../ui/src/components/Header/definition.ts | 6 ++ 5 files changed, 188 insertions(+), 24 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 1e549de36d..d49a512536 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1579,10 +1579,21 @@ export const HeaderDefinition: { readonly styles: { readonly [key: string]: string; }; + readonly bg: 'consumer'; readonly classNames: { readonly root: 'bui-Header'; + readonly beforeSticky: 'bui-HeaderBeforeSticky'; + readonly stickySentinel: 'bui-HeaderStickySentinel'; readonly content: 'bui-HeaderContent'; + readonly afterSticky: 'bui-HeaderAfterSticky'; readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall'; + readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink'; + readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall'; + readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator'; + readonly titleStack: 'bui-HeaderTitleStack'; + readonly title: 'bui-HeaderTitle'; + readonly titleSmall: 'bui-HeaderTitleSmall'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; @@ -1601,6 +1612,7 @@ export const HeaderDefinition: { readonly tags: {}; readonly metadata: {}; readonly className: {}; + readonly sticky: {}; }; }; @@ -1732,6 +1744,8 @@ export interface HeaderOwnProps { // (undocumented) metadata?: HeaderMetadataItem[]; // (undocumented) + sticky?: boolean; + // (undocumented) tabs?: HeaderNavTabItem[]; // (undocumented) tags?: HeaderTag[]; @@ -1750,10 +1764,21 @@ export const HeaderPageDefinition: { readonly styles: { readonly [key: string]: string; }; + readonly bg: 'consumer'; readonly classNames: { readonly root: 'bui-Header'; + readonly beforeSticky: 'bui-HeaderBeforeSticky'; + readonly stickySentinel: 'bui-HeaderStickySentinel'; readonly content: 'bui-HeaderContent'; + readonly afterSticky: 'bui-HeaderAfterSticky'; readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall'; + readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink'; + readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall'; + readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator'; + readonly titleStack: 'bui-HeaderTitleStack'; + readonly title: 'bui-HeaderTitle'; + readonly titleSmall: 'bui-HeaderTitleSmall'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; @@ -1772,6 +1797,7 @@ export const HeaderPageDefinition: { readonly tags: {}; readonly metadata: {}; readonly className: {}; + readonly sticky: {}; }; }; diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 08626268f7..0834767d2c 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -113,26 +113,93 @@ flex-shrink: 0; } - .bui-HeaderBreadcrumbs { + .bui-HeaderTitleStack { + position: relative; + flex: 1 1 auto; + height: calc(var(--bui-font-size-6) * 1.4); + min-width: 0; + overflow: hidden; + } + + .bui-HeaderBreadcrumbs, + .bui-HeaderBreadcrumbsSmall { + position: absolute; + inset-inline: 0; + top: 50%; display: flex; flex-direction: row; align-items: center; gap: var(--bui-space-2); min-width: 0; + transition: opacity 200ms ease, transform 200ms ease; } - .bui-HeaderTitle { - margin: 0; - padding: 0; + .bui-HeaderBreadcrumbs { + opacity: 1; + transform: translateY(-50%); + } + + .bui-HeaderBreadcrumbsSmall { + gap: var(--bui-space-1); + opacity: 0; + transform: translateY(25%); + } + + .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink, + .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { + max-width: 240px; overflow: hidden; font-family: var(--bui-font-regular); - font-size: var(--bui-font-size-6); font-weight: var(--bui-font-weight-bold); line-height: 140%; text-overflow: ellipsis; white-space: nowrap; } + .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink { + font-size: var(--bui-font-size-6); + } + + .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { + font-size: var(--bui-font-size-4); + } + + .bui-HeaderBreadcrumbSeparator { + flex-shrink: 0; + } + + .bui-HeaderTitle, + .bui-HeaderTitleSmall { + margin: 0; + padding: 0; + flex: 1 1 auto; + min-width: 0; + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-bold); + line-height: 140%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .bui-HeaderTitle { + font-size: var(--bui-font-size-6); + } + + .bui-HeaderTitleSmall { + font-size: var(--bui-font-size-4); + } + + .bui-HeaderContent[data-stuck] .bui-HeaderBreadcrumbs { + opacity: 0; + transform: translateY(-125%); + } + + .bui-HeaderContent[data-stuck] .bui-HeaderBreadcrumbsSmall { + opacity: 1; + transform: translateY(-50%); + } + .bui-HeaderTags { display: flex; flex-direction: row; diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 09dadce9f4..b77a4c16c1 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -483,3 +483,38 @@ export const StickyWithLongTitle = meta.story({ ), }); + +export const StickyWithBreadcrumbsAndLongTitle = meta.story({ + decorators: [withRouter], + render: () => ( + <> +
Custom action} + /> + + {Array.from({ length: 60 }, (_, i) => ( +

+ Scroll down to see the breadcrumb and long title bar stick to the + top while the tags, description, and metadata scroll away. Line{' '} + {i + 1}. +

+ ))} +
+ + ), +}); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index ae992f4b41..1313555daa 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -158,25 +158,55 @@ export const Header = (props: HeaderProps) => { data-sticky={sticky || undefined} data-stuck={isStuck || undefined} > -
- {breadcrumbs && - breadcrumbs.map(breadcrumb => ( - - - {breadcrumb.label} - - - - ))} -

{title}

+
+
+ {breadcrumbs && + breadcrumbs.map(breadcrumb => ( + + + {breadcrumb.label} + + + + ))} +

{title}

+
+
+ {breadcrumbs && + breadcrumbs.map(breadcrumb => ( + + + {breadcrumb.label} + + + + ))} +

{title}

+
{customActions}
diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index ace08f5195..4811871094 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -32,7 +32,13 @@ export const HeaderDefinition = defineComponent()({ content: 'bui-HeaderContent', afterSticky: 'bui-HeaderAfterSticky', breadcrumbs: 'bui-HeaderBreadcrumbs', + breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall', + breadcrumbLink: 'bui-HeaderBreadcrumbLink', + breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall', + breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator', + titleStack: 'bui-HeaderTitleStack', title: 'bui-HeaderTitle', + titleSmall: 'bui-HeaderTitleSmall', tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', tags: 'bui-HeaderTags', From dceca05fb49c22ace53b90d350f54d5dc32bad54 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 14:16:09 +0100 Subject: [PATCH 03/15] Fix non-sticky Header title duplication Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.tsx | 50 ++++++++++---------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 1313555daa..d46468799e 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -183,30 +183,32 @@ export const Header = (props: HeaderProps) => { ))}

{title}

-
- {breadcrumbs && - breadcrumbs.map(breadcrumb => ( - - - {breadcrumb.label} - - - - ))} -

{title}

-
+ {sticky && ( +
+ {breadcrumbs && + breadcrumbs.map(breadcrumb => ( + + + {breadcrumb.label} + + + + ))} +

{title}

+
+ )}
{customActions}
From 6c4606a71cfc19fdd00b60773680ec8a7df4b8fe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:05:45 +0100 Subject: [PATCH 04/15] Polish sticky Header implementation Signed-off-by: Charles de Dreuille --- .changeset/header-sticky-prop.md | 2 ++ packages/ui/src/components/Header/Header.module.css | 12 +++++++----- packages/ui/src/components/Header/Header.tsx | 8 ++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/header-sticky-prop.md b/.changeset/header-sticky-prop.md index 283c235d43..0e2722ed04 100644 --- a/.changeset/header-sticky-prop.md +++ b/.changeset/header-sticky-prop.md @@ -3,3 +3,5 @@ --- Added a `sticky` prop to the `Header` component. When `true`, the title-and-actions bar stays fixed to the top of its scroll container while the rest of the header (tags, description, metadata) scrolls away. The sticky bar background color automatically matches the container surface using the bg-consumer system. + +**Affected components:** Header diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 0834767d2c..0d6e9f7612 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -116,8 +116,10 @@ .bui-HeaderTitleStack { position: relative; flex: 1 1 auto; - height: calc(var(--bui-font-size-6) * 1.4); + height: 1lh; min-width: 0; + font-size: var(--bui-font-size-6); + line-height: 140%; overflow: hidden; } @@ -151,13 +153,13 @@ overflow: hidden; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); - line-height: 140%; + line-height: inherit; text-overflow: ellipsis; white-space: nowrap; } .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink { - font-size: var(--bui-font-size-6); + font-size: inherit; } .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { @@ -176,14 +178,14 @@ min-width: 0; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); - line-height: 140%; + line-height: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bui-HeaderTitle { - font-size: var(--bui-font-size-6); + font-size: inherit; } .bui-HeaderTitleSmall { diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index d46468799e..ad70508250 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -29,9 +29,11 @@ const getScrollParent = (element: HTMLElement | null): Element | null => { let parent = element?.parentElement; while (parent) { - const { overflowY } = window.getComputedStyle(parent); + const { overflow, overflowX, overflowY } = window.getComputedStyle(parent); - if (/(auto|scroll|overlay)/.test(overflowY)) { + if ( + /(auto|scroll|overlay|hidden)/.test(`${overflow}${overflowX}${overflowY}`) + ) { return parent; } @@ -85,6 +87,8 @@ export const Header = (props: HeaderProps) => { () => (description ? renderInlineMarkdown(description) : null), [description], ); + // The sentinel sits directly before the sticky content and leaves the + // viewport when the content becomes stuck, letting us toggle stuck styling. const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); From 6b05c0a4de6f8b5bbd54f968790afcd1f7fb123d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:20:12 +0100 Subject: [PATCH 05/15] 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; } From fc6f0d98e00ff44ba795aae827d671ba4a4ba8c5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:29:36 +0100 Subject: [PATCH 06/15] Remove display contents from sticky Header Signed-off-by: Charles de Dreuille --- .../src/components/Header/Header.module.css | 20 +- packages/ui/src/components/Header/Header.tsx | 241 ++++++++++-------- 2 files changed, 137 insertions(+), 124 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 0d6e9f7612..d8d0119771 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -25,20 +25,16 @@ padding-inline: var(--bui-space-5); } - .bui-Header[data-sticky] { - display: contents; - } - .bui-HeaderAfterSticky { display: flex; flex-direction: column; gap: var(--bui-space-3); } - .bui-Header[data-sticky] .bui-HeaderBeforeSticky, - .bui-Header[data-sticky] .bui-HeaderStickySentinel, - .bui-Header[data-sticky] .bui-HeaderContent, - .bui-Header[data-sticky] .bui-HeaderAfterSticky { + .bui-HeaderBeforeSticky[data-sticky], + .bui-HeaderStickySentinel[data-sticky], + .bui-HeaderContent[data-sticky], + .bui-HeaderAfterSticky[data-sticky] { width: 100%; padding-inline: var(--bui-space-5); box-sizing: border-box; @@ -48,7 +44,7 @@ padding-top: var(--bui-space-4); } - .bui-Header[data-sticky] .bui-HeaderBeforeSticky { + .bui-HeaderBeforeSticky[data-sticky] { padding-top: var(--bui-space-6); } @@ -72,15 +68,15 @@ z-index: 10; background-color: var(--bui-bg-app); - .bui-Header[data-on-bg='neutral-1'] & { + &[data-on-bg='neutral-1'] { background-color: var(--bui-bg-neutral-1); } - .bui-Header[data-on-bg='neutral-2'] & { + &[data-on-bg='neutral-2'] { background-color: var(--bui-bg-neutral-2); } - .bui-Header[data-on-bg='neutral-3'] & { + &[data-on-bg='neutral-3'] { background-color: var(--bui-bg-neutral-3); } } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 6e36d627f8..ab64dd6e7d 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -79,7 +79,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { * @public */ export const Header = (props: HeaderProps) => { - const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props); + const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props, { + classNameTarget: props.sticky ? 'content' : 'root', + }); const { classes, title, @@ -147,63 +149,70 @@ export const Header = (props: HeaderProps) => { }; }, [sticky]); - return ( -
- {tags && tags.length > 0 && ( -
-
    - {tags.map((tag, i) => ( -
  • 0 && ( +
    +
      + {tags.map((tag, i) => ( +
    • + {tag.href ? ( + - {tag.href ? ( - - {tag.label} - - ) : ( - - {tag.label} - - )} -
    • + {tag.label} + + ) : ( + + {tag.label} + + )} + + ))} +
    +
    + ); + + const titleAndActionsContent = ( + <> +
    +
    + {breadcrumbs && + breadcrumbs.map(breadcrumb => ( + + + {breadcrumb.label} + + + ))} -
+

{title}

- )} - {sticky && ( -