From 5351d8ac639e9dbafaf9aea8dc7bcc88c39fc6d0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 09:04:51 +0100 Subject: [PATCH] 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; } /**