diff --git a/.changeset/afraid-rats-invent.md b/.changeset/afraid-rats-invent.md
new file mode 100644
index 0000000000..0caa57d81c
--- /dev/null
+++ b/.changeset/afraid-rats-invent.md
@@ -0,0 +1,14 @@
+---
+'@backstage/ui': patch
+---
+
+Added a new `FullPage` component that fills the remaining viewport height below the `Header`.
+
+```tsx
+
+
+ {/* content fills remaining height */}
+
+```
+
+**Affected components:** FullPage
diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt
index 68c0fb0d52..779cc00cf7 100644
--- a/.github/vale/config/vocabularies/Backstage/accept.txt
+++ b/.github/vale/config/vocabularies/Backstage/accept.txt
@@ -547,6 +547,7 @@ validator
validators
Valkey
varchar
+viewport
vite
VMware
Vodafone
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index e67522fd87..dff52c85d6 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';
@@ -57,8 +57,6 @@ export default definePreview({
},
parameters: {
- layout: 'fullscreen',
-
backgrounds: {
disable: true,
},
@@ -146,9 +144,7 @@ export default definePreview({
{/* @ts-ignore */}
-
-
-
+
);
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 4d09023b69..b25b3c9b27 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -979,6 +979,21 @@ export interface FlexProps extends SpaceProps {
// @public (undocumented)
export type FlexWrap = 'wrap' | 'nowrap' | 'wrap-reverse';
+// @public
+export const FullPage: ForwardRefExoticComponent<
+ FullPageProps & RefAttributes
+>;
+
+// @public
+export const FullPageDefinition: {
+ readonly classNames: {
+ readonly root: 'bui-FullPage';
+ };
+};
+
+// @public
+export interface FullPageProps extends React.ComponentPropsWithoutRef<'main'> {}
+
// @public (undocumented)
export const Grid: {
Root: ForwardRefExoticComponent>;
@@ -1082,6 +1097,7 @@ export const Header: (props: HeaderProps) => JSX_2.Element;
// @public
export const HeaderDefinition: {
readonly classNames: {
+ readonly root: 'bui-Header';
readonly toolbar: 'bui-HeaderToolbar';
readonly toolbarWrapper: 'bui-HeaderToolbarWrapper';
readonly toolbarContent: 'bui-HeaderToolbarContent';
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..4cd94d30ea 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 {
@@ -90,7 +88,6 @@
}
.bui-HeaderTabsWrapper {
- margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
border-bottom: 1px solid var(--bui-border);
background-color: var(--bui-bg-neutral-1);
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx
index 56ec200e29..09f29b95c5 100644
--- a/packages/ui/src/components/Header/Header.tsx
+++ b/packages/ui/src/components/Header/Header.tsx
@@ -20,6 +20,8 @@ import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
import { HeaderDefinition } from './definition';
import { type NavigateOptions } from 'react-router-dom';
+import { useRef } from 'react';
+import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import styles from './Header.module.css';
import clsx from 'clsx';
@@ -47,9 +49,45 @@ export const Header = (props: HeaderProps) => {
} = cleanedProps;
const hasTabs = tabs && tabs.length > 0;
+ const headerRef = useRef(null);
+
+ useIsomorphicLayoutEffect(() => {
+ const el = headerRef.current;
+ if (!el) return undefined;
+
+ const updateHeight = () => {
+ const height = el.offsetHeight;
+ document.documentElement.style.setProperty(
+ '--bui-header-height',
+ `${height}px`,
+ );
+ };
+
+ // Set height once immediately
+ updateHeight();
+
+ // Observe for resize changes if ResizeObserver is available
+ // (not present in Jest/jsdom by default)
+ if (typeof ResizeObserver === 'undefined') {
+ return () => {
+ document.documentElement.style.removeProperty('--bui-header-height');
+ };
+ }
+
+ const observer = new ResizeObserver(updateHeight);
+ observer.observe(el);
+
+ return () => {
+ observer.disconnect();
+ document.documentElement.style.removeProperty('--bui-header-height');
+ };
+ }, []);
return (
- <>
+
{
className={clsx(
classNames.tabsWrapper,
styles[classNames.tabsWrapper],
- className,
)}
>
@@ -81,6 +118,6 @@ export const Header = (props: HeaderProps) => {
)}
- >
+
);
};
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';