Refine sticky Header title transition

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-04-26 09:26:12 +01:00
parent 5351d8ac63
commit eb1fabae6b
5 changed files with 188 additions and 24 deletions
+26
View File
@@ -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: {};
};
};
@@ -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;
@@ -483,3 +483,38 @@ export const StickyWithLongTitle = meta.story({
</>
),
});
export const StickyWithBreadcrumbsAndLongTitle = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="This is a very long page title that should demonstrate how sticky breadcrumbs and titles behave when both need to fit in the compact stuck state"
sticky
breadcrumbs={[
{ label: 'Home', href: '/' },
{ label: 'Very Long Breadcrumb Name', href: '/long-breadcrumb' },
]}
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 breadcrumb and long title bar stick to the
top while the tags, description, and metadata scroll away. Line{' '}
{i + 1}.
</p>
))}
</Container>
</>
),
});
+49 -19
View File
@@ -158,25 +158,55 @@ export const Header = (props: HeaderProps) => {
data-sticky={sticky || undefined}
data-stuck={isStuck || undefined}
>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={breadcrumb.href}
variant="title-small"
weight="bold"
color="secondary"
truncate
style={{ maxWidth: '240px' }}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine size={16} color="var(--bui-fg-secondary)" />
</Fragment>
))}
<h2 className={classes.title}>{title}</h2>
<div className={classes.titleStack}>
<div
className={classes.breadcrumbs}
aria-hidden={isStuck || undefined}
>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={isStuck ? undefined : breadcrumb.href}
color="secondary"
className={classes.breadcrumbLink}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine
className={classes.breadcrumbSeparator}
size={16}
color="var(--bui-fg-secondary)"
/>
</Fragment>
))}
<h2 className={classes.title}>{title}</h2>
</div>
<div
className={classes.breadcrumbsSmall}
aria-hidden={!isStuck || undefined}
>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={isStuck ? breadcrumb.href : undefined}
color="secondary"
className={classes.breadcrumbLinkSmall}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine
className={classes.breadcrumbSeparator}
size={16}
color="var(--bui-fg-secondary)"
/>
</Fragment>
))}
<h2 className={classes.titleSmall}>{title}</h2>
</div>
</div>
<div className={classes.controls}>{customActions}</div>
</div>
@@ -32,7 +32,13 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
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',