Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-card-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add Card component with subcomponents: Card.Icon, Card.Image, Card.Heading, Card.Description, Card.Menu, and Card.Metadata
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions e2e/components/Card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {test, expect} from '@playwright/test'
import {visit} from '../test-helpers/storybook'
import {themes} from '../test-helpers/themes'

test.describe('Card', () => {
test.describe('Default', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-card--default',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`Card.Default.${theme}.png`)
})
})
}
})

test.describe('With Image', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-card--with-image',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`Card.With Image.${theme}.png`)
})
})
}
})

test.describe('With Metadata', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-card--with-metadata',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`Card.With Metadata.${theme}.png`)
})
})
}
})
})
97 changes: 97 additions & 0 deletions packages/react/src/Card/Card.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
.Card {
display: grid;
position: relative;
border-radius: var(--borderRadius-large);
overflow: hidden;
grid-auto-rows: max-content auto;
border: var(--borderWidth-thin) solid var(--borderColor-default);
box-shadow: var(--shadow-resting-small);
background-color: var(--bgColor-default);
}

.CardHeader {
display: block;
width: 100%;
height: auto;
/* stylelint-disable primer/spacing */
padding: var(--stack-padding-spacious) var(--stack-padding-spacious) var(--stack-padding-normal)
var(--stack-padding-spacious);
/* stylelint-enable primer/spacing */
}

.CardHeaderEdgeToEdge {
padding: 0;
margin-bottom: var(--base-size-16);
}

.CardImage {
display: block;
width: 100%;
height: auto;
}

.CardIcon {
display: flex;
align-items: center;
justify-content: center;
width: var(--base-size-32);
height: var(--base-size-32);
border-radius: var(--borderRadius-medium);
background-color: var(--bgColor-muted);
color: var(--fgColor-muted);
}

.CardBody {
display: grid;
gap: var(--base-size-16);
/* stylelint-disable-next-line primer/spacing */
padding: 0 var(--stack-padding-spacious) var(--stack-padding-spacious) var(--stack-padding-spacious);
}

.CardContent {
display: grid;
gap: var(--base-size-8);
}

.CardHeading {
font-size: var(--text-body-size-large);
font-weight: var(--base-text-weight-semibold);
color: var(--fgColor-default);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}

.CardDescription {
font-size: var(--text-body-size-medium);
color: var(--fgColor-muted);
display: -webkit-box;
overflow: hidden;
Comment on lines +66 to +70
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.CardDescription only resets margin-bottom, leaving the default <p> top margin in place, which can create unintended extra spacing (especially since .CardContent already uses gap). Consider resetting the full margin (e.g. margin: 0) to keep spacing controlled by layout gaps.

Copilot uses AI. Check for mistakes.
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
margin: 0;
}

.CardMetadataContainer {
display: flex;
align-items: center;
gap: var(--base-size-16);
font-size: var(--text-body-size-medium);
color: var(--fgColor-muted);
}

.CardMetadataItem {
display: flex;
align-items: center;
gap: var(--base-size-8);
font: var(--text-body-shorthand-small);
}

.CardMenu {
position: absolute;
top: var(--base-size-16);
right: var(--base-size-16);
z-index: 1;
}
66 changes: 66 additions & 0 deletions packages/react/src/Card/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {Meta} from '@storybook/react-vite'
import {RocketIcon, RepoIcon, StarIcon} from '@primer/octicons-react'
import {Card} from './index'

const meta = {
title: 'Components/Card',
component: Card,
} satisfies Meta<typeof Card>

export default meta

export const Default = () => {
return (
<div style={{maxWidth: '400px'}}>
<Card>
<Card.Icon icon={RocketIcon} />
<Card.Heading>Card Heading</Card.Heading>
<Card.Description>This is a description of the card providing supplemental information.</Card.Description>
<Card.Metadata>Updated 2 hours ago</Card.Metadata>
</Card>
</div>
)
}

export const WithImage = () => {
return (
<div style={{maxWidth: '400px'}}>
<Card>
<Card.Image src="https://github.com/octocat.png" alt="Octocat" />
<Card.Heading>Card with Image</Card.Heading>
<Card.Description>This card uses an edge-to-edge image instead of an icon.</Card.Description>
</Card>
</div>
)
}

export const WithMetadata = () => {
return (
<div style={{maxWidth: '400px'}}>
<Card>
<Card.Icon icon={RepoIcon} />
<Card.Heading>primer/react</Card.Heading>
<Card.Description>
{"GitHub's design system implemented as React components for building consistent user interfaces."}
</Card.Description>
<Card.Metadata>
<StarIcon size={16} />
1.2k stars
</Card.Metadata>
</Card>
</div>
)
}

export const Playground = {
render: () => (
<div style={{maxWidth: '400px'}}>
<Card>
<Card.Icon icon={RocketIcon} />
<Card.Heading>Playground Card</Card.Heading>
<Card.Description>Experiment with the Card component and its subcomponents.</Card.Description>
<Card.Metadata>Just now</Card.Metadata>
</Card>
</div>
),
}
117 changes: 117 additions & 0 deletions packages/react/src/Card/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {describe, expect, it} from 'vitest'
import {render, screen} from '@testing-library/react'
import {Card} from '../Card'
import {implementsClassName} from '../utils/testing'
import classes from './Card.module.css'

const TestIcon = () => <svg data-testid="test-icon" aria-hidden="true" />

describe('Card', () => {
implementsClassName(props => <Card {...props} />, classes.Card)

it('should render a Card with heading and description', () => {
render(
<Card>
<Card.Heading>Test Heading</Card.Heading>
<Card.Description>Test Description</Card.Description>
</Card>,
)
expect(screen.getByText('Test Heading')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument()
})

it('should render a heading as an h3 element', () => {
render(
<Card>
<Card.Heading>Heading</Card.Heading>
</Card>,
)
expect(screen.getByRole('heading', {level: 3, name: 'Heading'})).toBeInTheDocument()
})

it('should render an icon', () => {
render(
<Card>
<Card.Icon icon={TestIcon} />
<Card.Heading>With Icon</Card.Heading>
</Card>,
)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})

it('should render an image', () => {
render(
<Card>
<Card.Image src="https://example.com/image.png" alt="Example" />
<Card.Heading>With Image</Card.Heading>
</Card>,
)
const img = screen.getByRole('img', {name: 'Example'})
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/image.png')
})

it('should render metadata', () => {
render(
<Card>
<Card.Heading>Metadata Card</Card.Heading>
<Card.Metadata>Updated 2 hours ago</Card.Metadata>
</Card>,
)
expect(screen.getByText('Updated 2 hours ago')).toBeInTheDocument()
})

it('should render a menu', () => {
render(
<Card>
<Card.Heading>Menu Card</Card.Heading>
<Card.Menu>
<button type="button">Options</button>
</Card.Menu>
</Card>,
)
expect(screen.getByRole('button', {name: 'Options'})).toBeInTheDocument()
})

it('should apply edge-to-edge styling when image is provided', () => {
const {container} = render(
<Card>
<Card.Image src="https://example.com/image.png" alt="" />
<Card.Heading>Edge to Edge</Card.Heading>
</Card>,
)
const header = container.querySelector(`.${classes.CardHeader}`)
expect(header).toHaveClass(classes.CardHeaderEdgeToEdge)
})

it('should not apply edge-to-edge styling when only icon is provided', () => {
const {container} = render(
<Card>
<Card.Icon icon={TestIcon} />
<Card.Heading>With Icon</Card.Heading>
</Card>,
)
const header = container.querySelector(`.${classes.CardHeader}`)
expect(header).not.toHaveClass(classes.CardHeaderEdgeToEdge)
})

it('should support a custom className on the root element', () => {
const {container} = render(
<Card className="custom-class">
<Card.Heading>Custom</Card.Heading>
</Card>,
)
expect(container.firstChild).toHaveClass('custom-class')
expect(container.firstChild).toHaveClass(classes.Card)
})

it('should forward a ref to the root element', () => {
const ref = {current: null as HTMLDivElement | null}
render(
<Card ref={ref}>
<Card.Heading>Ref Card</Card.Heading>
</Card>,
)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
})
})
Loading
Loading