diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md
new file mode 100644
index 0000000000..7f12c5372c
--- /dev/null
+++ b/.changeset/chatty-wasps-sink.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized.
+
+Affected components: Container
diff --git a/.storybook/modes.ts b/.storybook/modes.ts
index 7087fa35eb..ae4ff76a7c 100644
--- a/.storybook/modes.ts
+++ b/.storybook/modes.ts
@@ -15,4 +15,19 @@ export const allModes = {
themeMode: 'dark',
themeName: 'spotify',
},
+ 'light spotify neutral-1': {
+ themeMode: 'light',
+ themeName: 'spotify',
+ background: 'neutral-1',
+ },
+ 'light spotify neutral-2': {
+ themeMode: 'light',
+ themeName: 'spotify',
+ background: 'neutral-2',
+ },
+ 'light spotify neutral-3': {
+ themeMode: 'light',
+ themeName: 'spotify',
+ background: 'neutral-3',
+ },
} as const;
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 6a0177cafe..c5074f7749 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -3,7 +3,7 @@ import addonDocs from '@storybook/addon-docs';
import addonThemes from '@storybook/addon-themes';
import addonLinks from '@storybook/addon-links';
import { definePreview } from '@storybook/react-vite';
-import { useEffect } from 'react';
+import React, { useEffect } from 'react';
import { TestApiProvider } from '@backstage/test-utils';
import { AlertDisplay } from '@backstage/core-components';
import { apis } from './support/apis';
@@ -19,6 +19,7 @@ import './storybook.css';
// Custom themes
import './themes/spotify.css';
+import { Box } from '../packages/ui/src/components/Box';
export default definePreview({
tags: ['manifest'],
@@ -30,8 +31,8 @@ export default definePreview({
toolbar: {
icon: 'circlehollow',
items: [
- { value: 'light', icon: 'circlehollow', title: 'Light' },
- { value: 'dark', icon: 'circle', title: 'Dark' },
+ { value: 'light', icon: 'sun', title: 'Light' },
+ { value: 'dark', icon: 'moon', title: 'Dark' },
],
dynamicTitle: true,
},
@@ -49,11 +50,26 @@ export default definePreview({
dynamicTitle: true,
},
},
+ background: {
+ name: 'Background',
+ description: 'Global background for components',
+ defaultValue: 'app',
+ toolbar: {
+ icon: 'contrast',
+ items: [
+ { value: 'app', title: 'App Background' },
+ { value: 'neutral-1', title: 'Neutral 1 Background' },
+ { value: 'neutral-2', title: 'Neutral 2 Background' },
+ { value: 'neutral-3', title: 'Neutral 3 Background' },
+ ],
+ },
+ },
},
initialGlobals: {
themeMode: 'light',
themeName: 'backstage',
+ background: 'app',
},
parameters: {
@@ -70,7 +86,13 @@ export default definePreview({
options: {
storySort: {
- order: ['Backstage UI', 'Plugins', 'Layout', 'Navigation'],
+ order: [
+ 'Backstage UI',
+ 'Guidelines',
+ 'Plugins',
+ 'Layout',
+ 'Navigation',
+ ],
},
},
@@ -114,12 +136,14 @@ export default definePreview({
},
decorators: [
- Story => {
+ (Story, context) => {
const [globals] = useGlobals();
const selectedTheme =
globals.themeMode === 'light' ? themes.light : themes.dark;
const selectedThemeMode = globals.themeMode || 'light';
const selectedThemeName = globals.themeName || 'backstage';
+ const selectedBackground = globals.background || 'app';
+ const isFullscreen = context.parameters.layout === 'fullscreen';
useEffect(() => {
document.body.removeAttribute('data-theme-mode');
@@ -133,6 +157,8 @@ export default definePreview({
}, [selectedTheme, selectedThemeName]);
document.body.style.backgroundColor = 'var(--bui-bg-app)';
+ document.body.style.padding =
+ isFullscreen && selectedBackground !== 'app' ? '1rem' : '';
const docsStoryElements = document.getElementsByClassName('docs-story');
Array.from(docsStoryElements).forEach(element => {
(element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)';
@@ -143,7 +169,19 @@ export default definePreview({
{/* @ts-ignore */}
-
+ {Array.from({
+ length:
+ selectedBackground === 'app'
+ ? 0
+ : parseInt(selectedBackground.split('-')[1], 10),
+ }).reduce(
+ children => (
+
+ {children}
+
+ ),
+ ,
+ )}
);
diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css
index b72683f53e..89b71c48d3 100644
--- a/.storybook/themes/spotify.css
+++ b/.storybook/themes/spotify.css
@@ -213,6 +213,10 @@
.bui-Tag {
border-radius: var(--bui-radius-full);
}
+
+ .bui-Container {
+ padding-inline: 0;
+ }
}
[data-theme-mode='light'][data-theme-name='spotify'] {
diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx
index 5e5df2073b..2e2f94b4e1 100644
--- a/packages/ui/src/components/Button/Button.stories.tsx
+++ b/packages/ui/src/components/Button/Button.stories.tsx
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import preview from '../../../../../.storybook/preview';
+import { allModes } from '../../../../../.storybook/modes';
import { Button } from './Button';
import { Flex } from '../Flex';
import { Box } from '../Box';
@@ -55,100 +56,37 @@ export const Variants = meta.story({
control: false,
},
},
+ chromatic: {
+ modes: {
+ 'light spotify neutral-1': allModes['light spotify neutral-1'],
+ 'light spotify neutral-2': allModes['light spotify neutral-2'],
+ 'light spotify neutral-3': allModes['light spotify neutral-3'],
+ },
+ },
},
render: () => (
-
- Default
-
- } variant="primary">
- Button
-
- } variant="secondary">
- Button
-
- } variant="tertiary">
- Button
-
- } variant="primary" destructive>
- Button
-
- } variant="secondary" destructive>
- Button
-
- } variant="tertiary" destructive>
- Button
-
-
+
+ } variant="primary">
+ Button
+
+ } variant="secondary">
+ Button
+
+ } variant="tertiary">
+ Button
+
-
- Neutral 1
-
- } variant="primary">
- Button
-
- } variant="secondary">
- Button
-
- } variant="tertiary">
- Button
-
- } variant="primary" destructive>
- Button
-
- } variant="secondary" destructive>
- Button
-
- } variant="tertiary" destructive>
- Button
-
-
-
-
- Neutral 2
-
- } variant="primary">
- Button
-
- } variant="secondary">
- Button
-
- } variant="tertiary">
- Button
-
- } variant="primary" destructive>
- Button
-
- } variant="secondary" destructive>
- Button
-
- } variant="tertiary" destructive>
- Button
-
-
-
-
- Neutral 3
-
- } variant="primary">
- Button
-
- } variant="secondary">
- Button
-
- } variant="tertiary">
- Button
-
- } variant="primary" destructive>
- Button
-
- } variant="secondary" destructive>
- Button
-
- } variant="tertiary" destructive>
- Button
-
-
+
+ } variant="primary" destructive>
+ Button
+
+ } variant="secondary" destructive>
+ Button
+
+ } variant="tertiary" destructive>
+ Button
+
),
@@ -208,20 +146,6 @@ export const Destructive = meta.story({
-
- On Neutral 1
-
-
-
-
-
-
Sizes
diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css
index a0dec1e581..b93665525f 100644
--- a/packages/ui/src/components/Container/Container.module.css
+++ b/packages/ui/src/components/Container/Container.module.css
@@ -21,7 +21,7 @@
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
- transition: padding 0.2s ease-in-out;
+ padding-bottom: var(--bui-space-8);
}
@media (min-width: 640px) {
diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx
new file mode 100644
index 0000000000..51cc416423
--- /dev/null
+++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx
@@ -0,0 +1,279 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable no-restricted-syntax */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { MemoryRouter } from 'react-router-dom';
+import {
+ Card,
+ CardHeader,
+ CardBody,
+ Container,
+ Grid,
+ Flex,
+ Text,
+ Table,
+ CellText,
+ CellProfile,
+ useTable,
+ type ColumnConfig,
+ PluginHeader,
+ HeaderPage,
+ Button,
+} from '..';
+
+// ---------------------------------------------------------------------------
+// Metric card data
+// ---------------------------------------------------------------------------
+
+interface MetricCard {
+ label: string;
+ value: string;
+ trend: string;
+ trendColor: 'primary' | 'secondary' | 'success' | 'danger' | 'warning';
+}
+
+const metrics: MetricCard[] = [
+ {
+ label: 'Total Components',
+ value: '142',
+ trend: '+12 this week',
+ trendColor: 'success',
+ },
+ {
+ label: 'Active Services',
+ value: '58',
+ trend: '94% healthy',
+ trendColor: 'success',
+ },
+ {
+ label: 'Recent Deployments',
+ value: '23',
+ trend: 'in the last 24 h',
+ trendColor: 'secondary',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Table data
+// ---------------------------------------------------------------------------
+
+interface ServiceItem {
+ id: string;
+ name: string;
+ owner: string;
+ ownerAvatar: string;
+ type: 'service' | 'library' | 'website' | 'documentation';
+ lifecycle: 'production' | 'experimental';
+ description: string;
+}
+
+const services: ServiceItem[] = [
+ {
+ id: '1',
+ name: 'authentication-service',
+ owner: 'security-team',
+ ownerAvatar: 'https://github.com/identicons/security-team.png',
+ type: 'service',
+ lifecycle: 'production',
+ description: 'Handles user authentication, sessions and token refresh.',
+ },
+ {
+ id: '2',
+ name: 'api-gateway',
+ owner: 'platform-team',
+ ownerAvatar: 'https://github.com/identicons/platform-team.png',
+ type: 'service',
+ lifecycle: 'production',
+ description: 'Routes and validates all inbound API requests.',
+ },
+ {
+ id: '3',
+ name: 'frontend-core',
+ owner: 'design-system',
+ ownerAvatar: 'https://github.com/identicons/design-system.png',
+ type: 'library',
+ lifecycle: 'production',
+ description: 'Shared UI components and design tokens.',
+ },
+ {
+ id: '4',
+ name: 'data-pipeline',
+ owner: 'data-team',
+ ownerAvatar: 'https://github.com/identicons/data-team.png',
+ type: 'service',
+ lifecycle: 'experimental',
+ description: 'Streaming data ingestion pipeline for analytics.',
+ },
+ {
+ id: '5',
+ name: 'developer-portal',
+ owner: 'devex-team',
+ ownerAvatar: 'https://github.com/identicons/devex-team.png',
+ type: 'website',
+ lifecycle: 'production',
+ description: 'Internal developer portal built on Backstage.',
+ },
+ {
+ id: '6',
+ name: 'notification-service',
+ owner: 'platform-team',
+ ownerAvatar: 'https://github.com/identicons/platform-team.png',
+ type: 'service',
+ lifecycle: 'production',
+ description: 'Sends emails, Slack messages and push notifications.',
+ },
+ {
+ id: '7',
+ name: 'search-indexer',
+ owner: 'search-team',
+ ownerAvatar: 'https://github.com/identicons/search-team.png',
+ type: 'service',
+ lifecycle: 'experimental',
+ description: 'Indexes catalog entities for full-text search.',
+ },
+ {
+ id: '8',
+ name: 'billing-service',
+ owner: 'payments-team',
+ ownerAvatar: 'https://github.com/identicons/payments-team.png',
+ type: 'service',
+ lifecycle: 'production',
+ description: 'Manages subscriptions, invoices and payment processing.',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Stat card component
+// ---------------------------------------------------------------------------
+
+const StatCard = ({ label, value, trend, trendColor }: MetricCard) => (
+
+ {label}
+
+
+
+ {value}
+
+
+ {trend}
+
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Page layout component (the actual story render)
+// ---------------------------------------------------------------------------
+
+const columns: ColumnConfig[] = [
+ {
+ id: 'name',
+ label: 'Name',
+ isRowHeader: true,
+ defaultWidth: '3fr',
+ cell: item => (
+
+ ),
+ },
+ {
+ id: 'owner',
+ label: 'Owner',
+ defaultWidth: '2fr',
+ cell: item => ,
+ },
+ {
+ id: 'type',
+ label: 'Type',
+ defaultWidth: '1fr',
+ cell: item => ,
+ },
+ {
+ id: 'lifecycle',
+ label: 'Lifecycle',
+ defaultWidth: '1fr',
+ cell: item => (
+
+ ),
+ },
+];
+
+const CardsWithTableLayout = () => {
+ const { tableProps } = useTable({
+ mode: 'complete',
+ getData: () => services,
+ paginationOptions: { pageSize: 5 },
+ });
+
+ return (
+ <>
+
+ Custom action}
+ />
+
+
+
+ {metrics.map(metric => (
+
+ ))}
+
+
+
+
+ >
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Storybook meta
+// ---------------------------------------------------------------------------
+
+const meta = {
+ title: 'Guidelines/Cards with Table',
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story: () => JSX.Element) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};