Merge pull request #34103 from backstage/cd/plugin-header-spacing

fix(ui): adjust plugin header spacing
This commit is contained in:
Charles de Dreuille
2026-05-04 09:36:19 +01:00
committed by GitHub
11 changed files with 178 additions and 85 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Added stable DOM markers to the legacy Page and Header so adjacent layout components can coordinate spacing without relying on generated class names.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Adjusted PluginHeader spacing and borders so headers with and without tabs align more consistently with surrounding page content, including when paired with page headers.
**Affected components:** PluginHeader, Header
-24
View File
@@ -239,27 +239,3 @@
--bui-ring: rgba(255, 255, 255, 0.2);
}
/*
* Plugin header (@backstage/ui) and story shell header — kept at the bottom of
* this file for easier scanning alongside other component overrides above.
*/
[data-theme-name='spotify'] {
.bui-PluginHeaderToolbar {
height: 32px;
border: none;
background: none;
margin-top: var(--bui-space-4);
}
.bui-PluginHeaderTabsWrapper {
border: none;
background: none;
margin-left: 0;
margin-top: var(--bui-space-2);
}
.bui-HeaderTop {
padding-top: var(--bui-space-4);
}
}
@@ -228,7 +228,11 @@ export function Header(props: PropsWithChildren<Props>) {
return (
<>
<Helmet titleTemplate={titleTemplate} defaultTitle={defaultTitle} />
<header style={style} className={classes.header}>
<header
style={style}
className={classes.header}
data-backstage-core-header=""
>
<Box className={classes.leftItemsBox}>
<TypeFragment
classes={classes}
@@ -59,7 +59,12 @@ export function Page(props: Props) {
page: baseTheme.getPageTheme({ themeId }),
})}
>
<main className={classNames(classes.root, className)}>{children}</main>
<main
className={classNames(classes.root, className)}
data-backstage-core-page=""
>
{children}
</main>
</ThemeProvider>
);
}
+1
View File
@@ -60,6 +60,7 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-components": "workspace:^",
"@storybook/react-vite": "^10.3.3",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
@@ -32,10 +32,6 @@
box-sizing: border-box;
}
.bui-HeaderTop {
padding-top: var(--bui-space-6);
}
.bui-HeaderStickySentinel {
height: 1px;
margin-bottom: -1px;
@@ -17,15 +17,57 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-PluginHeader {
--bui-plugin-header-margin-bottom: var(--bui-space-6);
--bui-plugin-header-toolbar-border-bottom: solid 1px var(--bui-border-1);
--bui-plugin-header-tabs-border-bottom: 1px solid var(--bui-border-1);
--bui-plugin-header-toolbar-padding-bottom: var(--bui-space-4);
--bui-plugin-header-toolbar-tabs-padding-bottom: var(--bui-space-1);
--bui-plugin-header-background-color: transparent;
--bui-plugin-header-padding-top: var(--bui-space-4);
margin-bottom: var(--bui-plugin-header-margin-bottom);
padding-inline: var(--bui-space-5);
padding-top: var(--bui-plugin-header-padding-top);
background-color: var(--bui-plugin-header-background-color);
}
.bui-PluginHeader:has(+ .bui-HeaderTop),
.bui-PluginHeader:has(+ [data-backstage-core-header]),
.bui-PluginHeader:has(
+ [data-backstage-core-page] [data-backstage-core-header]
) {
--bui-plugin-header-margin-bottom: 0;
--bui-plugin-header-toolbar-border-bottom: none;
--bui-plugin-header-tabs-border-bottom: none;
--bui-plugin-header-toolbar-padding-bottom: var(--bui-space-2);
--bui-plugin-header-toolbar-tabs-padding-bottom: var(--bui-space-2);
--bui-plugin-header-background-color: var(--bui-bg-neutral-1);
--bui-plugin-header-padding-top: var(--bui-space-2);
}
.bui-PluginHeader:has(+ [data-backstage-core-header])
.bui-PluginHeaderToolbarIcon,
.bui-PluginHeader:has(
+ [data-backstage-core-page] [data-backstage-core-header]
)
.bui-PluginHeaderToolbarIcon {
background-color: var(--bui-bg-neutral-3);
}
.bui-PluginHeaderToolbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-inline: var(--bui-space-5);
color: var(--bui-fg-primary);
height: 52px;
border-bottom: 1px solid var(--bui-border-1);
border-bottom: var(--bui-plugin-header-toolbar-border-bottom);
padding-bottom: var(--bui-plugin-header-toolbar-padding-bottom);
&[data-has-tabs] {
border-bottom: none;
padding-bottom: var(--bui-plugin-header-toolbar-tabs-padding-bottom);
}
}
.bui-PluginHeaderToolbarContent {
@@ -45,7 +87,7 @@
flex-shrink: 0;
}
.bui-PluginHeaderToolbarIcon {
.bui-PluginHeader .bui-PluginHeaderToolbarIcon {
display: flex;
align-items: center;
justify-content: center;
@@ -69,7 +111,6 @@
}
.bui-PluginHeaderTabsWrapper {
padding-inline: var(--bui-space-3);
border-bottom: 1px solid var(--bui-border-1);
border-bottom: var(--bui-plugin-header-tabs-border-bottom);
}
}
@@ -25,7 +25,6 @@ import { Box } from '../Box';
import { Link } from '../Link';
import { RiShapesLine } from '@remixicon/react';
import { Text } from '../Text';
import { BgReset } from '../../hooks/useBg';
declare module 'react-aria-components' {
interface RouterConfig {
@@ -53,7 +52,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
} = ownProps;
const hasTabs = tabs && tabs.length > 0;
const headerRef = useRef<HTMLElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const lastAppliedHeightRef = useRef<number | undefined>(undefined);
@@ -62,7 +61,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
}, [customActions]);
useIsomorphicLayoutEffect(() => {
const el = headerRef.current;
const el = rootRef.current;
if (!el) {
return undefined;
}
@@ -125,50 +124,44 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const titleText = title || 'Your plugin';
return (
<BgReset>
<header ref={headerRef} className={classes.root}>
<Box bg="neutral" className={classes.toolbar} data-has-tabs={hasTabs}>
<div className={classes.toolbarContent}>
<Box
bg="neutral"
className={classes.toolbarIcon}
aria-hidden="true"
>
{icon || <RiShapesLine />}
</Box>
<h1 className={classes.toolbarName}>
{titleLink ? (
<Link href={titleLink} standalone variant="body-medium">
{titleText}
</Link>
) : (
<Text as="span" variant="body-medium">
{titleText}
</Text>
)}
</h1>
</div>
<div className={classes.toolbarControls}>{actionChildren}</div>
</Box>
{tabs && (
<Box bg="neutral" className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
<div ref={rootRef} className={classes.root}>
<div className={classes.toolbar} data-has-tabs={hasTabs ? '' : undefined}>
<div className={classes.toolbarContent}>
<Box bg="neutral" className={classes.toolbarIcon} aria-hidden="true">
{icon || <RiShapesLine />}
</Box>
)}
</header>
</BgReset>
<h1 className={classes.toolbarName}>
{titleLink ? (
<Link href={titleLink} standalone variant="body-medium">
{titleText}
</Link>
) : (
<Text as="span" variant="body-medium">
{titleText}
</Text>
)}
</h1>
</div>
<div className={classes.toolbarControls}>{actionChildren}</div>
</div>
{hasTabs && (
<div className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</div>
)}
</div>
);
};
@@ -15,6 +15,10 @@
*/
import preview from '../../../../.storybook/preview';
import {
Header as CoreHeader,
Page as CorePage,
} from '@backstage/core-components';
import type { StoryFn } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../provider';
@@ -49,7 +53,7 @@ import {
// ---------------------------------------------------------------------------
const PageContent = () => (
<Container mt="6">
<Container style={{ gridArea: 'pageContent' }}>
<Flex direction="row" gap="4">
<Card style={{ minHeight: 120, flex: 1 }} />
<Card style={{ minHeight: 120, flex: 1 }} />
@@ -59,7 +63,7 @@ const PageContent = () => (
);
const LongPageContent = () => (
<Container mt="6" pb="3">
<Container pb="3">
<Flex direction="row" gap="4" mb="4">
<Card style={{ minHeight: 200, flex: 1 }} />
<Card style={{ minHeight: 200, flex: 1 }} />
@@ -104,6 +108,28 @@ export const NoHeader = meta.story({
),
});
export const NoHeaderWithTabs = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiCodeSSlashLine />}
title="APIs"
tabs={[
{ id: 'overview', label: 'Overview', href: '/apis' },
{
id: 'definitions',
label: 'Definitions',
href: '/apis/definitions',
},
{ id: 'consumers', label: 'Consumers', href: '/apis/consumers' },
]}
/>
<PageContent />
</>
),
});
export const SimpleHeader = meta.story({
decorators: [withLayout],
render: () => (
@@ -115,6 +141,44 @@ export const SimpleHeader = meta.story({
),
});
export const CoreComponentsHeader = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader icon={<RiCodeSSlashLine />} title="APIs" />
<CorePage themeId="home">
<CoreHeader title="payments-api" />
<PageContent />
</CorePage>
</>
),
});
export const CoreComponentsHeaderWithTabs = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiCodeSSlashLine />}
title="APIs"
tabs={[
{ id: 'overview', label: 'Overview', href: '/apis' },
{
id: 'definitions',
label: 'Definitions',
href: '/apis/definitions',
},
{ id: 'consumers', label: 'Consumers', href: '/apis/consumers' },
]}
/>
<CorePage themeId="home">
<CoreHeader title="payments-api" />
<PageContent />
</CorePage>
</>
),
});
export const WithTabs = meta.story({
decorators: [withLayout],
render: () => (
+1
View File
@@ -7949,6 +7949,7 @@ __metadata:
resolution: "@backstage/ui@workspace:packages/ui"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@braintree/sanitize-url": "npm:^7.1.2"
"@internationalized/date": "npm:^3.12.0"