diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index e67522fd87..9f04a9b4a8 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -5,7 +5,7 @@ import addonLinks from '@storybook/addon-links';
import { definePreview } from '@storybook/react-vite';
import { useEffect } from 'react';
import { TestApiProvider } from '@backstage/test-utils';
-import { Content, AlertDisplay } from '@backstage/core-components';
+import { AlertDisplay } from '@backstage/core-components';
import { apis } from './support/apis';
import { useGlobals } from 'storybook/preview-api';
import { UnifiedThemeProvider, themes } from '@backstage/theme';
@@ -146,9 +146,7 @@ export default definePreview({
{/* @ts-ignore */}
-
-
-
+
);
diff --git a/packages/ui/src/components/FullPage/FullPage.module.css b/packages/ui/src/components/FullPage/FullPage.module.css
new file mode 100644
index 0000000000..7451d17cec
--- /dev/null
+++ b/packages/ui/src/components/FullPage/FullPage.module.css
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ .bui-FullPage {
+ height: calc(100dvh - var(--bui-header-height, 0px));
+ overflow-y: auto;
+ }
+}
diff --git a/packages/ui/src/components/FullPage/FullPage.stories.tsx b/packages/ui/src/components/FullPage/FullPage.stories.tsx
new file mode 100644
index 0000000000..8ac1b731e9
--- /dev/null
+++ b/packages/ui/src/components/FullPage/FullPage.stories.tsx
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import preview from '../../../../../.storybook/preview';
+import type { StoryFn } from '@storybook/react-vite';
+import { FullPage } from './FullPage';
+import { Header } from '../Header';
+import { Container } from '../Container';
+import { Text } from '../Text';
+import type { HeaderTab } from '../Header/types';
+import { MemoryRouter } from 'react-router-dom';
+
+const meta = preview.meta({
+ title: 'Backstage UI/FullPage',
+ component: FullPage,
+ parameters: {
+ layout: 'fullscreen',
+ },
+});
+
+const withRouter = (Story: StoryFn) => (
+
+
+
+);
+
+const tabs: HeaderTab[] = [
+ { id: 'overview', label: 'Overview', href: '/overview' },
+ { id: 'checks', label: 'Checks', href: '/checks' },
+ { id: 'tracks', label: 'Tracks', href: '/tracks' },
+ { id: 'campaigns', label: 'Campaigns', href: '/campaigns' },
+];
+
+const paragraphs = Array.from({ length: 20 }, (_, i) => (
+
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.
+ Pellentesque habitant morbi tristique senectus et netus et malesuada fames
+ ac turpis egestas. Sed do eiusmod tempor incididunt ut labore et dolore
+ magna aliqua.
+
+));
+
+export const Default = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ <>
+
+
+
+
+ This content fills the remaining viewport height below the Header.
+
+
+
+ >
+ ),
+});
+
+export const WithScrollableContent = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ <>
+
+
+
+
+ Scrollable Content
+
+
+ The content below scrolls independently while the Header stays
+ pinned at the top.
+
+ {paragraphs}
+
+
+ >
+ ),
+});
+
+export const WithTabs = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ <>
+
+
+
+
+ The FullPage height adjusts automatically when the Header includes
+ tabs, thanks to the ResizeObserver measuring the Header's actual
+ height.
+
+ {paragraphs}
+
+
+ >
+ ),
+});
diff --git a/packages/ui/src/components/FullPage/FullPage.tsx b/packages/ui/src/components/FullPage/FullPage.tsx
new file mode 100644
index 0000000000..dfe3a87948
--- /dev/null
+++ b/packages/ui/src/components/FullPage/FullPage.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { forwardRef } from 'react';
+import type { FullPageProps } from './types';
+import { useStyles } from '../../hooks/useStyles';
+import { FullPageDefinition } from './definition';
+import styles from './FullPage.module.css';
+import clsx from 'clsx';
+
+/**
+ * A component that fills the remaining viewport height below the Header.
+ *
+ * The FullPage component consumes the `--bui-header-height` CSS custom property
+ * set by the Header component to calculate its height as
+ * `calc(100dvh - var(--bui-header-height, 0px))`. Content inside the FullPage
+ * scrolls independently while the Header stays visible.
+ *
+ * @public
+ */
+export const FullPage = forwardRef((props, ref) => {
+ const { classNames, cleanedProps } = useStyles(FullPageDefinition, props);
+ const { className, ...rest } = cleanedProps;
+
+ return (
+
+ );
+});
diff --git a/packages/ui/src/components/FullPage/definition.ts b/packages/ui/src/components/FullPage/definition.ts
new file mode 100644
index 0000000000..db978dd7cc
--- /dev/null
+++ b/packages/ui/src/components/FullPage/definition.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ComponentDefinition } from '../../types';
+
+/**
+ * Component definition for FullPage
+ * @public
+ */
+export const FullPageDefinition = {
+ classNames: {
+ root: 'bui-FullPage',
+ },
+} as const satisfies ComponentDefinition;
diff --git a/packages/ui/src/components/FullPage/index.tsx b/packages/ui/src/components/FullPage/index.tsx
new file mode 100644
index 0000000000..0201803126
--- /dev/null
+++ b/packages/ui/src/components/FullPage/index.tsx
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { FullPage } from './FullPage';
+export { FullPageDefinition } from './definition';
+export type { FullPageProps } from './types';
diff --git a/packages/ui/src/components/FullPage/types.ts b/packages/ui/src/components/FullPage/types.ts
new file mode 100644
index 0000000000..fde8a12d93
--- /dev/null
+++ b/packages/ui/src/components/FullPage/types.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Props for the FullPage component.
+ *
+ * @public
+ */
+export interface FullPageProps extends React.ComponentPropsWithoutRef<'main'> {}
diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css
index cb51d60535..ba014cfd48 100644
--- a/packages/ui/src/components/Header/Header.module.css
+++ b/packages/ui/src/components/Header/Header.module.css
@@ -17,9 +17,11 @@
@layer tokens, base, components, utilities;
@layer components {
- .bui-HeaderToolbar {
- margin-bottom: var(--bui-space-6);
+ .bui-Header {
+ display: block;
+ }
+ .bui-HeaderToolbar {
&::before {
content: '';
position: absolute;
@@ -30,10 +32,6 @@
background-color: var(--bui-bg-neutral-0);
z-index: 0;
}
-
- &[data-has-tabs='true'] {
- margin-bottom: 0;
- }
}
.bui-HeaderToolbarWrapper {
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx
index 56ec200e29..080ec13b6a 100644
--- a/packages/ui/src/components/Header/Header.tsx
+++ b/packages/ui/src/components/Header/Header.tsx
@@ -20,6 +20,7 @@ import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
import { HeaderDefinition } from './definition';
import { type NavigateOptions } from 'react-router-dom';
+import { useRef, useEffect } from 'react';
import styles from './Header.module.css';
import clsx from 'clsx';
@@ -47,9 +48,35 @@ export const Header = (props: HeaderProps) => {
} = cleanedProps;
const hasTabs = tabs && tabs.length > 0;
+ const headerRef = useRef(null);
+
+ useEffect(() => {
+ const el = headerRef.current;
+ if (!el) return undefined;
+
+ const updateHeight = () => {
+ const height = el.offsetHeight;
+ document.documentElement.style.setProperty(
+ '--bui-header-height',
+ `${height}px`,
+ );
+ };
+
+ const observer = new ResizeObserver(updateHeight);
+ observer.observe(el);
+ updateHeight();
+
+ return () => {
+ observer.disconnect();
+ document.documentElement.style.removeProperty('--bui-header-height');
+ };
+ }, []);
return (
- <>
+
);
};
diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts
index 5969d7d8fa..6937bc5347 100644
--- a/packages/ui/src/components/Header/definition.ts
+++ b/packages/ui/src/components/Header/definition.ts
@@ -22,6 +22,7 @@ import type { ComponentDefinition } from '../../types';
*/
export const HeaderDefinition = {
classNames: {
+ root: 'bui-Header',
toolbar: 'bui-HeaderToolbar',
toolbarWrapper: 'bui-HeaderToolbarWrapper',
toolbarContent: 'bui-HeaderToolbarContent',
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index c9f2a3763c..40f37aba41 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -25,6 +25,7 @@ export * from './components/Box';
export * from './components/Grid';
export * from './components/Flex';
export * from './components/Container';
+export * from './components/FullPage';
// UI components
export * from './components/Accordion';