Add sticky Header support

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-04-26 09:04:51 +01:00
parent ad7d51bfec
commit 5351d8ac63
9 changed files with 333 additions and 99 deletions
+5
View File
@@ -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.
+18 -31
View File
@@ -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 */}
<TestApiProvider apis={apis}>
<AlertDisplay />
{Array.from({
length:
selectedBackground === 'app'
? 0
: parseInt(selectedBackground.split('-')[1], 10),
}).reduce<React.ReactNode>(
children => (
<Box bg="neutral" p="4">
{children}
</Box>
),
<Story />,
{selectedThemeName === 'spotify' ? (
<Box
bg="neutral"
m={isFullscreen ? '4' : undefined}
style={{
borderRadius: 'var(--bui-radius-3)',
height: isFullscreen
? 'calc(100vh - (var(--bui-space-4) * 2))'
: undefined,
overflow: 'auto',
overscrollBehavior: 'none',
}}
>
<Story />
</Box>
) : (
<Story />
)}
</TestApiProvider>
</UnifiedThemeProvider>
-4
View File
@@ -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'] {
@@ -18,6 +18,8 @@
@layer components {
.bui-Container {
flex: 1;
width: 100%;
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
@@ -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 {
@@ -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: () => (
<>
<Header
title="Sticky Page Title"
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the title bar stick to the top while the tags,
description, and metadata scroll away. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
export const Sticky = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="Sticky Page Title"
sticky
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the title bar stick to the top while the tags,
description, and metadata scroll away. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
export const StickyWithLongTitle = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="This is a very long page title that should demonstrate how the sticky Header behaves when the title takes up significantly more horizontal space than usual"
sticky
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the long title bar stick to the top while the
tags, description, and metadata scroll away. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
+125 -63
View File
@@ -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<HTMLDivElement>(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 (
<Container className={classes.root}>
<header
className={classes.root}
data-sticky={sticky || undefined}
{...dataAttributes}
>
{tags && tags.length > 0 && (
<ul className={classes.tags}>
{tags.map((tag, i) => (
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
</li>
))}
</ul>
<div className={classes.beforeSticky}>
<ul className={classes.tags}>
{tags.map((tag, i) => (
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
</li>
))}
</ul>
</div>
)}
<div className={classes.content}>
{sticky && (
<div
ref={stickySentinelRef}
className={classes.stickySentinel}
aria-hidden="true"
/>
)}
<div
className={classes.content}
data-sticky={sticky || undefined}
data-stuck={isStuck || undefined}
>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
@@ -116,47 +176,49 @@ export const Header = (props: HeaderProps) => {
<RiArrowRightSLine size={16} color="var(--bui-fg-secondary)" />
</Fragment>
))}
<Text variant="title-small" weight="bold" as="h2">
{title}
</Text>
<h2 className={classes.title}>{title}</h2>
</div>
<div className={classes.controls}>{customActions}</div>
</div>
{description && (
<Text
variant="body-medium"
color="secondary"
className={classes.description}
>
{descriptionNodes}
</Text>
)}
{metadata && metadata.length > 0 && (
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
{(description || (metadata && metadata.length > 0) || tabs) && (
<div className={classes.afterSticky}>
{description && (
<Text
variant="body-medium"
color="secondary"
className={classes.description}
>
{descriptionNodes}
</Text>
)}
{metadata && metadata.length > 0 && (
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
</div>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
</div>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
)}
</div>
)}
</Container>
</header>
);
};
@@ -24,10 +24,15 @@ import styles from './Header.module.css';
*/
export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
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<HeaderOwnProps>()({
tags: {},
metadata: {},
className: {},
sticky: {},
},
});
@@ -116,6 +116,7 @@ export interface HeaderOwnProps {
tags?: HeaderTag[];
metadata?: HeaderMetadataItem[];
className?: string;
sticky?: boolean;
}
/**