diff --git a/projects/js-packages/components/changelog/update-placement-card-shared-component b/projects/js-packages/components/changelog/update-placement-card-shared-component
new file mode 100644
index 000000000000..e9a50391a870
--- /dev/null
+++ b/projects/js-packages/components/changelog/update-placement-card-shared-component
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add a CheckboxCard component.
diff --git a/projects/js-packages/components/components/checkbox-card/index.tsx b/projects/js-packages/components/components/checkbox-card/index.tsx
new file mode 100644
index 000000000000..d16028fe72f3
--- /dev/null
+++ b/projects/js-packages/components/components/checkbox-card/index.tsx
@@ -0,0 +1,79 @@
+import { useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Card, Checkbox, Field, Link, Stack, Text } from '@wordpress/ui';
+import clsx from 'clsx';
+import './style.scss';
+import type { CheckboxCardGridProps, CheckboxCardProps } from './types.ts';
+import type { JSX } from 'react';
+
+/**
+ * Lays out checkbox cards in two columns, or one on narrow screens.
+ *
+ * @param {CheckboxCardGridProps} props - Component props.
+ * @return {JSX.Element} The grid.
+ */
+export function CheckboxCardGrid( { children, className }: CheckboxCardGridProps ): JSX.Element {
+ return
{ children }
;
+}
+
+/**
+ * A card with a checkbox, toggled by a click on its illustration.
+ *
+ * @param {CheckboxCardProps} props - Component props.
+ * @return {JSX.Element} The card.
+ */
+export default function CheckboxCard( {
+ id,
+ name,
+ title,
+ illustration,
+ checked,
+ onChange,
+ previewUrl,
+ onPreviewClick,
+ disabled,
+}: CheckboxCardProps ): JSX.Element {
+ const titleId = `${ id }__title`;
+
+ const handleChange = useCallback(
+ ( next: boolean ) => onChange( name, next ),
+ [ name, onChange ]
+ );
+
+ const handlePreviewClick = useCallback( () => {
+ onPreviewClick?.( name );
+ }, [ name, onPreviewClick ] );
+
+ return (
+
+
+
+
+ { /* Only the illustration toggles: the title and link sit outside the label. */ }
+ { illustration }
+
+
+
+
+
+
+ { title }
+
+ { previewUrl && (
+
+ { __( 'Preview and edit', 'jetpack-components' ) }
+
+ ) }
+
+
+ );
+}
+
+export { CheckboxCard };
+export type { CheckboxCardProps, CheckboxCardGridProps };
diff --git a/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx b/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx
new file mode 100644
index 000000000000..bab83dc42ccf
--- /dev/null
+++ b/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx
@@ -0,0 +1,90 @@
+import { useCallback, useState } from 'react';
+import CheckboxCard, { CheckboxCardGrid } from '../index.tsx';
+import type { Meta, StoryFn } from '@storybook/react';
+
+const meta: Meta< typeof CheckboxCard > = {
+ title: 'JS Packages/Components/Checkbox Card',
+ component: CheckboxCard,
+ argTypes: {
+ illustration: { table: { disable: true } },
+ onChange: { table: { disable: true } },
+ onPreviewClick: { table: { disable: true } },
+ },
+};
+
+export default meta;
+
+const Wireframe = ( { before }: { before?: boolean } ) => {
+ const top = before ? 56 : 24;
+ return (
+
+ );
+};
+
+const Template: StoryFn< typeof CheckboxCard > = args => {
+ const [ checked, setChecked ] = useState( args.checked );
+ const handleChange = useCallback( ( _name: string, next: boolean ) => setChecked( next ), [] );
+
+ return (
+
+
+
+ );
+};
+
+export const _default = Template.bind( {} );
+_default.args = {
+ id: 'placement-after-content',
+ name: 'after_content',
+ title: 'After the post content',
+ illustration: ,
+ checked: false,
+};
+
+export const WithPreviewLink = Template.bind( {} );
+WithPreviewLink.args = {
+ ..._default.args,
+ checked: true,
+ previewUrl: '#',
+};
+
+export const Disabled = Template.bind( {} );
+Disabled.args = {
+ ..._default.args,
+ disabled: true,
+};
+
+export const Grid: StoryFn< typeof CheckboxCard > = () => {
+ const [ selected, setSelected ] = useState< Record< string, boolean > >( {
+ before_content: true,
+ } );
+ const handleChange = useCallback(
+ ( name: string, next: boolean ) => setSelected( prev => ( { ...prev, [ name ]: next } ) ),
+ []
+ );
+
+ return (
+
+ }
+ checked={ Boolean( selected.before_content ) }
+ onChange={ handleChange }
+ />
+ }
+ checked={ Boolean( selected.after_content ) }
+ onChange={ handleChange }
+ />
+
+ );
+};
diff --git a/projects/js-packages/components/components/checkbox-card/style.scss b/projects/js-packages/components/components/checkbox-card/style.scss
new file mode 100644
index 000000000000..015741a4e540
--- /dev/null
+++ b/projects/js-packages/components/components/checkbox-card/style.scss
@@ -0,0 +1,54 @@
+@use "@automattic/jetpack-base-styles/gutenberg-base-styles" as gb;
+
+.jp-checkbox-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ column-gap: var(--wpds-dimension-gap-md);
+ row-gap: var(--wpds-dimension-gap-2xl);
+
+ @media (max-width: 720px) {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.jp-checkbox-card {
+
+ &[data-checked="true"] {
+ border-color: var(--wpds-color-stroke-interactive-brand-active);
+ }
+
+ &:hover:not([data-checked="true"]) {
+ border-color: var(--wpds-color-stroke-surface-neutral-strong);
+ }
+
+ &:has(:focus-visible) {
+
+ @include gb.outset-ring__focus();
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: border-color 0.15s ease-out;
+ }
+}
+
+.jp-checkbox-card__surface {
+ position: relative;
+}
+
+.jp-checkbox-card__illustration {
+ display: flex;
+ aspect-ratio: 294 / 192;
+ background: var(--wpds-color-background-surface-neutral-weak);
+ cursor: var(--wpds-cursor-control);
+
+ > svg {
+ inline-size: 100%;
+ block-size: 100%;
+ }
+}
+
+.jp-checkbox-card__checkbox {
+ position: absolute;
+ inset-block-start: var(--wpds-dimension-padding-md);
+ inset-inline-start: var(--wpds-dimension-padding-md);
+}
diff --git a/projects/js-packages/components/components/checkbox-card/test/component.tsx b/projects/js-packages/components/components/checkbox-card/test/component.tsx
new file mode 100644
index 000000000000..1e2567d53306
--- /dev/null
+++ b/projects/js-packages/components/components/checkbox-card/test/component.tsx
@@ -0,0 +1,73 @@
+import { jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import CheckboxCard from '../index.tsx';
+import type { CheckboxCardProps } from '../types.ts';
+
+const renderCard = ( props: Partial< CheckboxCardProps > = {} ) => {
+ const onChange = jest.fn();
+ const onPreviewClick = jest.fn();
+ render(
+ Wireframe }
+ checked={ false }
+ onChange={ onChange }
+ onPreviewClick={ onPreviewClick }
+ { ...props }
+ />
+ );
+ return { onChange, onPreviewClick };
+};
+
+describe( 'CheckboxCard', () => {
+ // Catches the title id and `aria-labelledby` drifting apart, which leaves the checkbox unnamed.
+ it( 'names the checkbox with the title', () => {
+ renderCard( { checked: true } );
+
+ expect( screen.getByRole( 'checkbox', { name: 'After the post content' } ) ).toBeChecked();
+ } );
+
+ // Catches the label's `htmlFor` no longer matching the checkbox, or `onChange` losing the name.
+ it.each( [
+ [ 'ticks', false, true ],
+ [ 'unticks', true, false ],
+ ] )( '%s the card when the illustration is clicked', async ( _, checked, next ) => {
+ const { onChange } = renderCard( { checked } );
+
+ await userEvent.click( screen.getByText( 'Wireframe' ) );
+
+ expect( onChange ).toHaveBeenCalledWith( 'after_content', next );
+ } );
+
+ // Catches the title moving inside the label, where a click meant for the link toggles the card.
+ it( 'does not toggle when the title is clicked', async () => {
+ const { onChange } = renderCard();
+
+ await userEvent.click( screen.getByText( 'After the post content' ) );
+
+ expect( onChange ).not.toHaveBeenCalled();
+ } );
+
+ // Catches the link rendering without a `previewUrl`, or going missing when one is passed.
+ it.each( [
+ [ 'shows', 'https://example.com/wp-admin/site-editor.php', 1 ],
+ [ 'hides', undefined, 0 ],
+ ] )( '%s the preview link', ( _, previewUrl, count ) => {
+ renderCard( { previewUrl } );
+
+ // By text: without an `href` the anchor has no link role, so a role query would miss it.
+ expect( screen.queryAllByText( 'Preview and edit' ) ).toHaveLength( count );
+ } );
+
+ // Catches the preview handler losing the name the parent uses to tell cards apart.
+ it( 'passes its name to the preview handler', async () => {
+ const { onPreviewClick } = renderCard( { previewUrl: '#preview' } );
+
+ await userEvent.click( screen.getByRole( 'link', { name: 'Preview and edit' } ) );
+
+ expect( onPreviewClick ).toHaveBeenCalledWith( 'after_content' );
+ } );
+} );
diff --git a/projects/js-packages/components/components/checkbox-card/types.ts b/projects/js-packages/components/components/checkbox-card/types.ts
new file mode 100644
index 000000000000..eb6beb325de2
--- /dev/null
+++ b/projects/js-packages/components/components/checkbox-card/types.ts
@@ -0,0 +1,51 @@
+import type { ReactNode } from 'react';
+
+export type CheckboxCardProps = {
+ /**
+ * Id of the checkbox; the title's id is built from it.
+ */
+ id: string;
+ /**
+ * Passed back to `onChange` and `onPreviewClick`, so one handler can serve every card.
+ */
+ name: string;
+ /**
+ * Caption under the card, and the checkbox's accessible name.
+ */
+ title: string;
+ /**
+ * Picture filling the card, drawn at a 294×192 aspect ratio.
+ */
+ illustration?: ReactNode;
+ /**
+ * Whether the card is checked.
+ */
+ checked: boolean;
+ /**
+ * Called with the card's `name` and the next checked value.
+ */
+ onChange: ( name: string, next: boolean ) => void;
+ /**
+ * Shows a "Preview and edit" link to this URL under the title.
+ */
+ previewUrl?: string;
+ /**
+ * Called with the card's `name` when the "Preview and edit" link is clicked.
+ */
+ onPreviewClick?: ( name: string ) => void;
+ /**
+ * Disables the checkbox.
+ */
+ disabled?: boolean;
+};
+
+export type CheckboxCardGridProps = {
+ /**
+ * The `CheckboxCard`s to lay out.
+ */
+ children: ReactNode;
+ /**
+ * Extra class for the grid.
+ */
+ className?: string;
+};
diff --git a/projects/js-packages/components/index.ts b/projects/js-packages/components/index.ts
index 96fe8a3f0620..8b32e6bf4396 100644
--- a/projects/js-packages/components/index.ts
+++ b/projects/js-packages/components/index.ts
@@ -24,6 +24,8 @@ export { default as AutomatticForAgenciesLogo } from './components/automattic-fo
export { default as JetpackFooter } from './components/jetpack-footer/index.tsx';
export { default as Gravatar } from './components/gravatar/index.tsx';
export type { GravatarProps } from './components/gravatar/index.tsx';
+export { default as CheckboxCard, CheckboxCardGrid } from './components/checkbox-card/index.tsx';
+export type { CheckboxCardProps, CheckboxCardGridProps } from './components/checkbox-card/types.ts';
export { default as IconTooltip } from './components/icon-tooltip/index.tsx';
export { default as ActionButton } from './components/action-button/index.tsx';
export { default as PricingCard } from './components/pricing-card/index.tsx';
diff --git a/projects/js-packages/components/package.json b/projects/js-packages/components/package.json
index 21fed2ae8173..6fc5aadd8ff4 100644
--- a/projects/js-packages/components/package.json
+++ b/projects/js-packages/components/package.json
@@ -33,6 +33,11 @@
"types": "./build/components/button/index.d.ts",
"default": "./build/components/button/index.js"
},
+ "./checkbox-card": {
+ "jetpack:src": "./components/checkbox-card/index.tsx",
+ "types": "./build/components/checkbox-card/index.d.ts",
+ "default": "./build/components/checkbox-card/index.js"
+ },
"./global-notices": {
"jetpack:src": "./components/global-notices/index.ts",
"types": "./build/components/global-notices/index.d.ts",
diff --git a/projects/packages/newsletter/changelog/update-placement-card-shared-component b/projects/packages/newsletter/changelog/update-placement-card-shared-component
new file mode 100644
index 000000000000..6bd2d6bb951b
--- /dev/null
+++ b/projects/packages/newsletter/changelog/update-placement-card-shared-component
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Settings: Use the shared CheckboxCard component for subscription placements.
diff --git a/projects/packages/newsletter/src/settings/sections/placement-card.scss b/projects/packages/newsletter/src/settings/sections/placement-card.scss
deleted file mode 100644
index 746b9a32d92a..000000000000
--- a/projects/packages/newsletter/src/settings/sections/placement-card.scss
+++ /dev/null
@@ -1,96 +0,0 @@
-// PlacementCard — selected / hover / focus states layered on top of the
-// `Card.Root` primitive. The card surface itself (border, radius, background,
-// overflow:clip, flex column) comes from `Card.Root`; this file only carries
-// the interactive-state colours and the absolute checkbox positioning.
-
-.jetpack-newsletter-placements-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- // Tighter horizontal gap between the two columns; more breathing room
- // between rows so the second row reads as a separate band.
- column-gap: var(--wpds-dimension-gap-md);
- row-gap: var(--wpds-dimension-gap-2xl);
-
- @media (max-width: 720px) {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
-.jetpack-newsletter-placement__card {
- // Position context for the absolute-positioned checkbox below.
- position: relative;
-
- // Selected state: brand-toned active stroke (the WPDS primary blue).
- &[data-checked="true"] {
- border-color: var(--wpds-color-stroke-interactive-brand-active);
- }
-
- &:hover:not([data-checked="true"]) {
- border-color: var(--wpds-color-stroke-surface-neutral-strong);
- }
-
- // Focus ring on the card itself (not the checkbox input), using the
- // canonical brand focus token.
- &:has(input:focus-visible) {
- outline:
- var(--wpds-border-width-focus) solid
- var(--wpds-color-stroke-focus);
- outline-offset: 2px;
- }
-
- // Only animate the border-color when the user hasn't asked to reduce
- // motion — respects `prefers-reduced-motion: reduce`.
- @media (prefers-reduced-motion: no-preference) {
- transition: border-color 0.15s ease-out;
- }
-}
-
-.jetpack-newsletter-placement__surface {
- display: block;
- cursor: var(--wpds-cursor-control);
-}
-
-.jetpack-newsletter-placement__illustration {
- // Match the v2 SVG's intrinsic 294×192 aspect so the wireframe
- // fills the card surface edge-to-edge.
- aspect-ratio: 294 / 192;
- background: var(--wpds-color-background-surface-neutral-weak);
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
-
- > svg {
- display: block;
- inline-size: 100%;
- block-size: 100%;
- }
-}
-
-.jetpack-newsletter-placement__checkbox {
- position: absolute;
- inset-block-start: var(--wpds-dimension-padding-md);
- inset-inline-start: var(--wpds-dimension-padding-md);
-
- // CheckboxControl from `@wordpress/components` wraps the native input in
- // BaseControl chrome (margin/padding from `__field` + `__input-container`).
- // `@wordpress/ui` doesn't yet ship a Form.Checkbox primitive, so until it
- // does we zero the chrome here. Revisit once WP UI exposes a checkbox.
- .components-base-control,
- .components-base-control__field,
- .components-checkbox-control,
- .components-checkbox-control__input-container {
- margin: 0;
- padding: 0;
- }
-
- .components-checkbox-control__input {
- margin: 0;
- }
-
- // The empty fallback