feat(ui): add description, tags, and metadata props to Header

Add three new props to the Header component:
- `tags`: renders a row of text/link items above the title, separated by
  3×3px circle dividers
- `description`: renders a markdown string below the title with inline
  link support via react-markdown
- `metadata`: renders key-value pairs below the description with 20px gaps

Also deprecates the `breadcrumbs` prop for future removal.

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
Made-with: Cursor
This commit is contained in:
Charles de Dreuille
2026-04-19 09:48:56 +02:00
parent c3ca20c864
commit c96e2b3445
7 changed files with 193 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `description`, `tags`, and `metadata` props to the `Header` component. The `description` prop accepts a markdown string with support for inline links. The `tags` prop renders a row of text or link items above the title. The `metadata` prop renders key-value pairs below the description. The `breadcrumbs` prop has been deprecated and will be removed in a future release.
**Affected components:** Header
+1
View File
@@ -52,6 +52,7 @@
"clsx": "^2.1.1",
"react-aria": "~3.48.0",
"react-aria-components": "~1.17.0",
"react-markdown": "^8.0.0",
"react-stately": "~3.46.0",
"use-sync-external-store": "^1.4.0"
},
@@ -47,4 +47,28 @@
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderTags {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
flex-wrap: wrap;
}
.bui-HeaderTagDivider {
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--bui-fg-secondary);
flex-shrink: 0;
}
.bui-HeaderMetaRow {
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
}
@@ -152,6 +152,58 @@ export const WithLongBreadcrumbs = meta.story({
},
});
export const WithDescription = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
description:
'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.',
},
});
export const WithTags = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
},
});
export const WithMetadata = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
});
export const WithDescriptionTagsAndMetadata = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
description:
'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.',
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
});
export const WithEverything = meta.story({
decorators: [withRouter],
args: {
@@ -159,6 +211,18 @@ export const WithEverything = meta.story({
tabs,
customActions: <Button>Custom action</Button>,
breadcrumbs: [{ label: 'Home', href: '/' }],
description:
'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.',
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
});
+60 -2
View File
@@ -23,6 +23,7 @@ import { HeaderDefinition } from './definition';
import { Container } from '../Container';
import { Link } from '../Link';
import { Fragment } from 'react/jsx-runtime';
import ReactMarkdown from 'react-markdown';
/**
* A secondary header with title, breadcrumbs, tabs, and actions.
@@ -31,11 +32,38 @@ import { Fragment } from 'react/jsx-runtime';
*/
export const Header = (props: HeaderProps) => {
const { ownProps } = useDefinition(HeaderDefinition, props);
const { classes, title, tabs, activeTabId, customActions, breadcrumbs } =
ownProps;
const {
classes,
title,
tabs,
activeTabId,
customActions,
breadcrumbs,
description,
tags,
metadata,
} = ownProps;
return (
<Container className={classes.root}>
{tags && tags.length > 0 && (
<div className={classes.tags}>
{tags.map((tag, i) => (
<Fragment key={tag.label}>
{i > 0 && <span className={classes.tagDivider} aria-hidden />}
{tag.href ? (
<Link href={tag.href} variant="body-small" standalone>
{tag.label}
</Link>
) : (
<Text variant="body-small" color="secondary">
{tag.label}
</Text>
)}
</Fragment>
))}
</div>
)}
<div className={classes.content}>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
@@ -61,6 +89,36 @@ export const Header = (props: HeaderProps) => {
</div>
<div className={classes.controls}>{customActions}</div>
</div>
{description && (
<ReactMarkdown
className={classes.description}
allowedElements={['p', 'a', 'strong', 'em']}
unwrapDisallowed
components={{
p: ({ children }) => (
<Text variant="body-medium" color="secondary">
{children}
</Text>
),
a: ({ href, children }) => (
<Link href={href ?? ''} standalone>
{children}
</Link>
),
}}
>
{description}
</ReactMarkdown>
)}
{metadata && metadata.length > 0 && (
<div className={classes.metaRow}>
{metadata.map(item => (
<Text key={item.label} variant="body-small" color="secondary">
<strong>{item.label}:</strong> {item.value}
</Text>
))}
</div>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
@@ -30,6 +30,10 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
breadcrumbs: 'bui-HeaderBreadcrumbs',
tabsWrapper: 'bui-HeaderTabsWrapper',
controls: 'bui-HeaderControls',
tags: 'bui-HeaderTags',
tagDivider: 'bui-HeaderTagDivider',
description: 'bui-HeaderDescription',
metaRow: 'bui-HeaderMetaRow',
},
propDefs: {
title: {},
@@ -37,6 +41,9 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tabs: {},
activeTabId: {},
breadcrumbs: {},
description: {},
tags: {},
metadata: {},
className: {},
},
});
@@ -52,6 +52,26 @@ export interface HeaderNavTabGroup {
*/
export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
/**
* Represents a tag item in the header.
*
* @public
*/
export interface HeaderTag {
label: string;
href?: string;
}
/**
* Represents a metadata key-value pair in the header.
*
* @public
*/
export interface HeaderMetadataItem {
label: string;
value: React.ReactNode;
}
/**
* Own props for the Header component.
*
@@ -62,7 +82,17 @@ export interface HeaderOwnProps {
customActions?: React.ReactNode;
tabs?: HeaderNavTabItem[];
activeTabId?: string | null;
/**
* @deprecated The breadcrumbs prop will be removed in a future release.
*/
breadcrumbs?: HeaderBreadcrumb[];
/**
* Markdown string rendered below the title. Only inline elements are
* supported (links, bold, italic). Block-level markdown is not rendered.
*/
description?: string;
tags?: HeaderTag[];
metadata?: HeaderMetadataItem[];
className?: string;
}