diff --git a/.changeset/brown-rings-sort.md b/.changeset/brown-rings-sort.md
new file mode 100644
index 0000000000..045f2fbd3b
--- /dev/null
+++ b/.changeset/brown-rings-sort.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added interactive support to the `Card` component. Pass `onPress` to make the entire card surface pressable, or `href` to make it navigate to a URL. A transparent overlay handles the interaction while nested buttons and links remain independently clickable.
diff --git a/docs-ui/src/app/components/card/components.tsx b/docs-ui/src/app/components/card/components.tsx
index d10e4d7bce..c4d1f809c2 100644
--- a/docs-ui/src/app/components/card/components.tsx
+++ b/docs-ui/src/app/components/card/components.tsx
@@ -7,6 +7,8 @@ import {
CardFooter,
} from '../../../../../packages/ui/src/components/Card/Card';
import { Text } from '../../../../../packages/ui/src/components/Text/Text';
+import { Button } from '../../../../../packages/ui/src/components/Button/Button';
+import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
export const Default = () => {
return (
@@ -32,6 +34,70 @@ export const HeaderAndBody = () => {
);
};
+export const InteractiveButton = () => {
+ return (
+ {}}
+ label="View component details"
+ >
+ Interactive Card
+
+ Click anywhere on this card to trigger the press handler.
+
+
+
+ Click to interact
+
+
+
+ );
+};
+
+export const InteractiveLink = () => {
+ return (
+
+ Link Card
+ This card navigates to a URL when clicked.
+
+
+ Opens backstage.io
+
+
+
+ );
+};
+
+export const InteractiveWithNestedButtons = () => {
+ return (
+ {}}
+ label="View plugin details"
+ >
+ Card with Actions
+
+ Clicking the card background triggers the card press handler. The
+ buttons below remain independently interactive.
+
+
+
+
+
+
+
+
+ );
+};
+
export const WithLongBody = () => {
return (
diff --git a/docs-ui/src/app/components/card/page.mdx b/docs-ui/src/app/components/card/page.mdx
index 6f410fcd97..df4d531604 100644
--- a/docs-ui/src/app/components/card/page.mdx
+++ b/docs-ui/src/app/components/card/page.mdx
@@ -12,8 +12,18 @@ import {
defaultSnippet,
headerAndBodySnippet,
withLongBodySnippet,
+ interactiveButtonSnippet,
+ interactiveLinkSnippet,
+ interactiveWithNestedButtonsSnippet,
} from './snippets';
-import { Default, HeaderAndBody, WithLongBody } from './components';
+import {
+ Default,
+ HeaderAndBody,
+ WithLongBody,
+ InteractiveButton,
+ InteractiveLink,
+ InteractiveWithNestedButtons,
+} from './components';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { CardDefinition } from '../../../utils/definitions';
@@ -81,6 +91,49 @@ When body content exceeds the available height, CardBody scrolls while header an
code={withLongBodySnippet}
/>
+## Interactive cards
+
+Cards can be made interactive without wrapping the entire card in a button or link — which would conflict with any interactive elements inside. Instead, a transparent overlay covers the card surface, and nested buttons and links remain independently clickable above it.
+
+### Button
+
+Pass `onPress` and a `label` (used as the accessible name for screen readers) to make the whole card surface pressable.
+
+}
+ code={interactiveButtonSnippet}
+/>
+
+### Link
+
+Pass `href` to make the card surface navigate to a URL. The `label` prop is optional but recommended for accessibility when the card content alone may not sufficiently describe the destination.
+
+}
+ code={interactiveLinkSnippet}
+/>
+
+### With nested buttons
+
+Buttons and links inside the card remain independently interactive. Clicking them does not trigger the card's `onPress` handler.
+
+}
+ code={interactiveWithNestedButtonsSnippet}
+/>
+
diff --git a/docs-ui/src/app/components/card/props-definition.ts b/docs-ui/src/app/components/card/props-definition.ts
index b3e3776648..a0084c7158 100644
--- a/docs-ui/src/app/components/card/props-definition.ts
+++ b/docs-ui/src/app/components/card/props-definition.ts
@@ -15,6 +15,25 @@ const optionalChildrenPropDef: Record = {
export const cardPropDefs: Record = {
...optionalChildrenPropDef,
+ onPress: {
+ type: 'enum',
+ values: ['() => void'],
+ responsive: false,
+ description:
+ 'Handler called when the card is pressed. Makes the card interactive as a button. Requires label.',
+ },
+ href: {
+ type: 'string',
+ responsive: false,
+ description:
+ 'URL to navigate to. Makes the card interactive as a link. Mutually exclusive with onPress.',
+ },
+ label: {
+ type: 'string',
+ responsive: false,
+ description:
+ 'Accessible label announced by screen readers for the interactive overlay. Required when onPress is provided.',
+ },
...classNamePropDefs,
...stylePropDefs,
};
diff --git a/docs-ui/src/app/components/card/snippets.ts b/docs-ui/src/app/components/card/snippets.ts
index 6f956e5518..86b9df5e0a 100644
--- a/docs-ui/src/app/components/card/snippets.ts
+++ b/docs-ui/src/app/components/card/snippets.ts
@@ -17,6 +17,50 @@ export const headerAndBodySnippet = `Body content without a footer
`;
+export const interactiveButtonSnippet = ` console.log('Card pressed')}
+ label="View component details"
+>
+ Interactive Card
+ Click anywhere on this card to trigger the press handler.
+ Click to interact
+`;
+
+export const interactiveLinkSnippet = `
+ Link Card
+ This card navigates to a URL when clicked.
+ Opens backstage.io
+`;
+
+export const interactiveWithNestedButtonsSnippet = `import { Button, Flex } from '@backstage/ui';
+
+ console.log('Card pressed')}
+ label="View plugin details"
+>
+ Card with Actions
+
+ Clicking the card background triggers the card press handler.
+ The buttons below remain independently interactive.
+
+
+
+
+
+
+
+`;
+
export const withLongBodySnippet = `import { Text } from '@backstage/ui';
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index f1d3505ccf..503ec19414 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -565,6 +565,12 @@ export const Card: ForwardRefExoticComponent<
CardProps & RefAttributes
>;
+// @public (undocumented)
+export type CardBaseProps = {
+ children?: ReactNode;
+ className?: string;
+};
+
// @public
export const CardBody: ForwardRefExoticComponent<
CardBodyProps & RefAttributes
@@ -595,6 +601,13 @@ export interface CardBodyProps
extends CardBodyOwnProps,
React.HTMLAttributes {}
+// @public (undocumented)
+export type CardButtonVariant = {
+ onPress: () => void;
+ href?: never;
+ label: string;
+};
+
// @public
export const CardDefinition: {
readonly styles: {
@@ -602,10 +615,14 @@ export const CardDefinition: {
};
readonly classNames: {
readonly root: 'bui-Card';
+ readonly overlay: 'bui-CardOverlay';
};
readonly propDefs: {
readonly children: {};
readonly className: {};
+ readonly onPress: {};
+ readonly href: {};
+ readonly label: {};
};
};
@@ -670,15 +687,32 @@ export interface CardHeaderProps
React.HTMLAttributes {}
// @public (undocumented)
-export type CardOwnProps = {
- children?: ReactNode;
- className?: string;
+export type CardLinkVariant = {
+ href: string;
+ onPress?: never;
+ label?: string;
};
// @public
-export interface CardProps
- extends CardOwnProps,
- React.HTMLAttributes {}
+export type CardOwnProps = {
+ children?: ReactNode;
+ className?: string;
+ onPress?: () => void;
+ href?: string;
+ label?: string;
+};
+
+// @public
+export type CardProps = CardBaseProps &
+ Omit, 'onPress'> &
+ (CardButtonVariant | CardLinkVariant | CardStaticVariant);
+
+// @public (undocumented)
+export type CardStaticVariant = {
+ onPress?: never;
+ href?: never;
+ label?: never;
+};
// @public (undocumented)
export const Cell: {
diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css
index 445873f6ba..5e59d1e930 100644
--- a/packages/ui/src/components/Card/Card.module.css
+++ b/packages/ui/src/components/Card/Card.module.css
@@ -27,6 +27,48 @@
overflow: hidden;
min-height: 0;
width: 100%;
+ position: relative;
+ }
+
+ .bui-CardOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ background: transparent;
+ cursor: pointer;
+ border-radius: inherit;
+ border: none;
+ padding: 0;
+ appearance: none;
+ display: block;
+ width: 100%;
+
+ &:focus-visible {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: -2px;
+ }
+
+ &:hover::after,
+ &[data-focused]::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, currentColor 5%, transparent);
+ border-radius: inherit;
+ pointer-events: none;
+ }
+ }
+
+ /*
+ * Interactive elements inside the card must sit above the overlay so they
+ * remain independently clickable. The overlay handles the rest of the surface.
+ */
+ .bui-Card[data-interactive]
+ :is(button, a[href], [role='button'], [role='link'], input, select, textarea):not(
+ .bui-CardOverlay
+ ) {
+ position: relative;
+ z-index: 2;
}
.bui-CardBody {
diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx
index 3ddde49806..41b43b89a4 100644
--- a/packages/ui/src/components/Card/Card.stories.tsx
+++ b/packages/ui/src/components/Card/Card.stories.tsx
@@ -231,6 +231,94 @@ export const BgOnProviders = meta.story({
),
});
+export const Interactive = meta.story({
+ render: () => (
+ alert('Card pressed')}
+ label="View component details"
+ >
+
+ Interactive Card
+
+
+
+ Click anywhere on this card to trigger the press handler. The entire
+ card surface is interactive.
+
+
+
+
+ Click to interact
+
+
+
+ ),
+});
+
+export const InteractiveAsLink = meta.story({
+ render: () => (
+
+
+ Link Card
+
+
+
+ This card navigates to a URL when clicked. The entire card surface
+ acts as a link.
+
+
+
+
+ Opens backstage.io
+
+
+
+ ),
+});
+
+export const InteractiveWithNestedButtons = meta.story({
+ render: () => (
+ alert('Card pressed')}
+ label="View plugin details"
+ >
+
+ Card with Actions
+
+
+
+ Clicking the card background triggers the card press handler. The
+ buttons below remain independently interactive.
+
+
+
+
+
+
+
+
+
+ ),
+});
+
export const CustomCardWithBox = meta.story({
render: () => (
diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx
index 5eb09f2edc..cd09daf576 100644
--- a/packages/ui/src/components/Card/Card.tsx
+++ b/packages/ui/src/components/Card/Card.tsx
@@ -15,6 +15,7 @@
*/
import { forwardRef } from 'react';
+import { Button as RAButton, Link as RALink } from 'react-aria-components';
import { useDefinition } from '../../hooks/useDefinition';
import {
CardDefinition,
@@ -23,6 +24,7 @@ import {
CardFooterDefinition,
} from './definition';
import type {
+ CardOwnProps,
CardProps,
CardHeaderProps,
CardBodyProps,
@@ -38,18 +40,31 @@ import { Box } from '../Box/Box';
export const Card = forwardRef((props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
CardDefinition,
- props,
+ props as CardOwnProps &
+ Omit, 'onPress'>,
);
- const { classes, children } = ownProps;
+ const { classes, children, onPress, href, label } = ownProps;
+ const isInteractive = !!(onPress || href);
return (
+ {href && (
+
+ )}
+ {onPress && !href && (
+
+ )}
{children}
);
diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts
index 6493d01839..814b42aa9d 100644
--- a/packages/ui/src/components/Card/definition.ts
+++ b/packages/ui/src/components/Card/definition.ts
@@ -31,10 +31,14 @@ export const CardDefinition = defineComponent()({
styles,
classNames: {
root: 'bui-Card',
+ overlay: 'bui-CardOverlay',
},
propDefs: {
children: {},
className: {},
+ onPress: {},
+ href: {},
+ label: {},
},
});
diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts
index e31b2cfda2..a00e0b797f 100644
--- a/packages/ui/src/components/Card/types.ts
+++ b/packages/ui/src/components/Card/types.ts
@@ -16,10 +16,44 @@
import type { ReactNode } from 'react';
-/** @public */
+/**
+ * Flat own-props shape used by the component definition system.
+ * @public
+ */
export type CardOwnProps = {
children?: ReactNode;
className?: string;
+ onPress?: () => void;
+ href?: string;
+ label?: string;
+};
+
+/** @public */
+export type CardBaseProps = { children?: ReactNode; className?: string };
+
+/** @public */
+export type CardButtonVariant = {
+ /** Handler called when the card is pressed. Makes the card interactive as a button. */
+ onPress: () => void;
+ href?: never;
+ /** Accessible label announced by screen readers for the interactive card. */
+ label: string;
+};
+
+/** @public */
+export type CardLinkVariant = {
+ /** URL to navigate to. Makes the card interactive as a link. */
+ href: string;
+ onPress?: never;
+ /** Accessible label announced by screen readers for the interactive card. */
+ label?: string;
+};
+
+/** @public */
+export type CardStaticVariant = {
+ onPress?: never;
+ href?: never;
+ label?: never;
};
/**
@@ -27,9 +61,9 @@ export type CardOwnProps = {
*
* @public
*/
-export interface CardProps
- extends CardOwnProps,
- React.HTMLAttributes {}
+export type CardProps = CardBaseProps &
+ Omit, 'onPress'> &
+ (CardButtonVariant | CardLinkVariant | CardStaticVariant);
/** @public */
export type CardHeaderOwnProps = {