Skip to content

UI: Add CardRadio component - #85401

Draft
mirka wants to merge 3 commits into
trunkfrom
ui-radio-card-pattern-story
Draft

mirka wants to merge 3 commits into
trunkfrom
ui-radio-card-pattern-story

Conversation

@mirka

@mirka mirka commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

What?

See #76850

Adds CardRadio to @wordpress/ui: a radio option presented as a card, for high-emphasis single selection such as choosing a plan or a payment method. Clicking anywhere on the card selects it, and the card accepts additional non-interactive content below the label and description.

Options are grouped with the existing Fieldset.Root and RadioGroup primitives, so there is no CardRadio.Root:

<Fieldset.Root render={ <RadioGroup defaultValue="visa" /> }>
	<Fieldset.Legend>Payment method</Fieldset.Legend>
	<CardRadio value="visa" label="Visa" description="Ending 1234" />
	<CardRadio value="mastercard" label="Mastercard" description="Ending 5678">
		{ /* Optional non-interactive content */ }
	</CardRadio>
</Fieldset.Root>

Why?

#76850 proposes a high-emphasis alternative to a plain radio list. This PR started as a story composing Radio with Card. The composition works with public components, but every consumer would have to copy the CSS that stretches the label over the card, styles the checked, hover, and disabled states, and moves the focus ring to the card. It also needs a Field.Root, which is easy to miss and throws when it is absent. A small component removes that boilerplate.

How?

  1. CardRadio follows CheckboxControl: it renders its own Field.Root (as a Card.Root) and Field.Item, so label names the radio and description describes it. The ref and other props go to the Radio, and className goes to the card.
  2. The radio stays a small control inside the card instead of the card becoming the radio. role="radio" treats its children as plain text, so a whole-card radio would flatten headings, lists, and images in the additional content, and its name or description would include all of the card's text. Here, additional content keeps its semantics and is not part of the radio's name or description.
  3. A positioned ::after on the label covers the card, so clicking anywhere selects the radio. This also prevents interaction with the card's content, so interactive content is not supported.
  4. The card's checked, hover, disabled, and focus styles use :has() on the radio's data-checked, data-disabled, and :focus-visible. The focus ring uses the package's focus-ring mixin on the card, and the radio's own ring is hidden.
  5. Grouping, the legend, name, required, and the group's layout stay with Fieldset.Root and RadioGroup. A per-card Field.Root inside a RadioGroup behaves the same as a shared one for names, descriptions, keyboard navigation, form values, and required.
  6. The component is marked "use with caution" in Storybook and in the use-recommended-components ESLint rule, because the design in Add RadioCard to @wordpress/ui #76850 is still being finalized.

The earlier Radio card story is replaced by the CardRadio stories.

Testing Instructions

  1. Run npm run storybook:dev.
  2. Open "Components / @wordpress-ui / Form / CardRadio / Default".
  3. Click the description or empty space in the "Mastercard" card. The radio is selected and the card border turns blue.
  4. Hover an unselected card. Its border darkens.
  5. Click the "American Express" card. Nothing changes, because it is disabled.
  6. Open "With Additional Content" and click the gradient or the text in a card. That card is selected.
  7. With a screen reader, check that each radio is announced with its label as the name and its description, and that the additional content is not part of either.

Testing Instructions for Keyboard

  1. Tab into the group. Focus lands on the selected radio, and the focus ring is drawn around its card.
  2. Press the arrow keys. Selection and the focus ring move between "Visa" and "Mastercard" and skip the disabled option.
  3. Click a card with the mouse. No focus ring is shown.

Screenshots or screencast

Default Keyboard focus on "Visa"
Three radio cards under a Payment method legend. Visa is selected with a blue border, Mastercard is unselected, and American Express is disabled with a grey background. The same radio cards with a focus ring around the selected Visa card and no separate ring around its radio.

With additional content, in a grid:

Three plan cards in a row under a Plan legend, each with a label, a price, a gradient image, and a line of text. The Commerce card is selected.

Show how to compose `Radio` with `Card` for high-emphasis single-select options, without a dedicated component.

See #76850.
@mirka mirka added [Type] Developer Documentation Documentation for developers [Package] UI /packages/ui labels Oct 22, 2026
@mirka mirka self-assigned this Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground

📦 Bundle size

Size Change: 0 B

Total Size: 9.05 MB

From eb27c8f, not the current head. Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Regression: updating a specific option will only change its value and will not corrupt others in /test/e2e/specs/editor/various/core-settings.spec.js, passed after 1 failed attempt.
Error: expect(received).toStrictEqual(expected) // deep equality

- Expected  - 1
+ Received  + 1

  Array [
    "rewrite_rules",
-   "SERIALIZED DATA",
+   "",
  ]
    at forEach (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:46:26)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:43:35
Should navigate inner blocks with arrow keys in /test/e2e/specs/editor/various/writing-flow.spec.js, passed after 1 failed attempt.
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
  - waiting for getByRole('listbox', { name: 'Blocks' }).getByRole('option', { name: 'Paragraph' })

    at WritingFlowUtils.addDemoContent (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/writing-flow.spec.js:1594:5)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/writing-flow.spec.js:30:3
refuses the drop and uploads nothing in /test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js, passed after 1 failed attempt.
Error: expect(received).toHaveLength(expected)

Expected length: 0
Received length: 1
Received array:  [{"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=223"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/223", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/222", "id": 222, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-223", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-10-22T16:20:13", "date_gmt": "2026-10-22T16:20:13", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/10/2418f75c-5d3c-4201-a843-8050730efd46.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/10/2418f75c-5d3c-4201-a843-8050730efd46.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "2418f75c-5d3c-4201-a843-8050730efd46.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/10/2418f75c-5d3c-4201-a843-8050730efd46.png"}, "id": 223, "link": "http://localhost:8889/?attachment_id=223", "media_details": {"file": "2026/10/2418f75c-5d3c-4201-a843-8050730efd46.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-10-22T16:20:13", "modified_gmt": "2026-10-22T16:20:13", "ping_status": "closed", "post": 222, "slug": "2418f75c-5d3c-4201-a843-8050730efd46", "source_url": "http://localhost:8889/wp-content/uploads/2026/10/2418f75c-5d3c-4201-a843-8050730efd46.png", "status": "inherit", "template": "", "title": {"rendered": "2418f75c-5d3c-4201-a843-8050730efd46"}, "type": "attachment"}]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js:71:45

From eb27c8f, not the current head. Run

mirka added 2 commits October 23, 2026 01:02
Use the focus ring mixin on the card when its radio has visible focus, and hide the radio's own ring so that only one ring shows.
Replace the Radio card story with a CardRadio component. It renders a radio option as a card, where clicking anywhere on the card selects the radio, and accepts additional non-interactive content. Consumers group options with Fieldset and RadioGroup.

See #76850.
@github-actions github-actions Bot added the [Tool] ESLint plugin /packages/eslint-plugin label Oct 22, 2026
@mirka mirka added [Type] Enhancement A suggestion for improvement. and removed [Type] Developer Documentation Documentation for developers labels Oct 22, 2026
@mirka mirka changed the title UI: Add Radio story showing a card pattern UI: Add CardRadio component Oct 22, 2026
@mirka
mirka force-pushed the ui-radio-card-pattern-story branch from 206a056 to 01978db Compare October 23, 2026 02:21

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] UI /packages/ui [Tool] ESLint plugin /packages/eslint-plugin [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant