diff --git a/canon-docs/src/content/components/radio-group.mdx b/canon-docs/src/content/components/radio-group.mdx
new file mode 100644
index 0000000000..703ac6b8dd
--- /dev/null
+++ b/canon-docs/src/content/components/radio-group.mdx
@@ -0,0 +1,98 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { RadioGroupSnippet } from '@/snippets/stories-snippets';
+import {
+ radioGroupPropDefs,
+ radioGroupUsageSnippet,
+ radioGroupDefaultSnippet,
+ radioGroupDescriptionSnippet,
+ radioGroupHorizontalSnippet,
+ radioGroupDisabledSnippet,
+ radioGroupDisabledSingleSnippet,
+ radioGroupValidationSnippet,
+ radioGroupReadOnlySnippet,
+} from './radio-group.props';
+import { ComponentInfos } from '@/components/ComponentInfos';
+
+# RadioGroup
+
+A radio group allows a user to select a single item from a list of mutually exclusive options.
+
+}
+ code={radioGroupDefaultSnippet}
+/>
+
+
+
+## API reference
+
+
+
+## Examples
+
+### Horizontal
+
+Here's a simple TextField with a description.
+
+}
+ code={radioGroupHorizontalSnippet}
+/>
+
+### Disabled
+
+You can disable the entire radio group by adding the `isDisabled` prop to the `RadioGroup` component.
+
+}
+ code={radioGroupDisabledSnippet}
+/>
+
+### Disabled Single radio
+
+You can disable a single radio by adding the `isDisabled` prop to the `Radio` component.
+
+}
+ code={radioGroupDisabledSingleSnippet}
+/>
+
+### Validation
+
+Here's an example of a radio group with errors.
+
+}
+ code={radioGroupValidationSnippet}
+/>
+
+### Read only
+
+You can make the radio group read only by adding the `isReadOnly` prop to the `RadioGroup` component.
+
+}
+ code={radioGroupReadOnlySnippet}
+/>
diff --git a/canon-docs/src/content/components/radio-group.props.ts b/canon-docs/src/content/components/radio-group.props.ts
new file mode 100644
index 0000000000..c016be5ac6
--- /dev/null
+++ b/canon-docs/src/content/components/radio-group.props.ts
@@ -0,0 +1,64 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+
+export const radioGroupPropDefs: Record = {
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ },
+ label: {
+ type: 'string',
+ },
+ icon: {
+ type: 'enum',
+ values: ['ReactNode'],
+ },
+ description: {
+ type: 'string',
+ },
+ name: {
+ type: 'string',
+ required: true,
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const radioGroupUsageSnippet = `import { RadioGroup } from '@backstage/canon';
+
+`;
+
+export const radioGroupDefaultSnippet = ``;
+
+export const radioGroupDescriptionSnippet = ``;
+
+export const radioGroupHorizontalSnippet = ``;
+
+export const radioGroupDisabledSnippet = `
+ Bulbasaur
+ Charmander
+ Squirtle
+`;
+
+export const radioGroupDisabledSingleSnippet = `
+ Bulbasaur
+ Charmander
+ Squirtle
+`;
+
+export const radioGroupValidationSnippet = ` (value === \'charmander\' ? \'Nice try!\' : null)>
+ Bulbasaur
+ Charmander
+ Squirtle
+`;
+
+export const radioGroupReadOnlySnippet = `
+ Bulbasaur
+ Charmander
+ Squirtle
+`;
diff --git a/canon-docs/src/snippets/stories-snippets.tsx b/canon-docs/src/snippets/stories-snippets.tsx
index e5a7464b43..ef25a37900 100644
--- a/canon-docs/src/snippets/stories-snippets.tsx
+++ b/canon-docs/src/snippets/stories-snippets.tsx
@@ -18,6 +18,7 @@ import * as MenuStories from '../../../packages/canon/src/components/Menu/Menu.s
import * as LinkStories from '../../../packages/canon/src/components/Link/Link.stories';
import * as AvatarStories from '../../../packages/canon/src/components/Avatar/Avatar.stories';
import * as CollapsibleStories from '../../../packages/canon/src/components/Collapsible/Collapsible.stories';
+import * as RadioGroupStories from '../../../packages/canon/src/components/RadioGroup/RadioGroup.stories';
import * as TabsStories from '../../../packages/canon/src/components/Tabs/Tabs.stories';
import * as SwitchStories from '../../../packages/canon/src/components/Switch/Switch.stories';
@@ -197,3 +198,14 @@ export const SwitchSnippet = ({
return StoryComponent ? : null;
};
+
+export const RadioGroupSnippet = ({
+ story,
+}: {
+ story: keyof typeof RadioGroupStories;
+}) => {
+ const stories = composeStories(RadioGroupStories);
+ const StoryComponent = stories[story as keyof typeof stories];
+
+ return StoryComponent ? : null;
+};
diff --git a/canon-docs/src/utils/data.ts b/canon-docs/src/utils/data.ts
index a741ad5160..92f0fdbf87 100644
--- a/canon-docs/src/utils/data.ts
+++ b/canon-docs/src/utils/data.ts
@@ -116,6 +116,11 @@ export const components: Page[] = [
slug: 'menu',
status: 'alpha',
},
+ {
+ title: 'RadioGroup',
+ slug: 'radio-group',
+ status: 'alpha',
+ },
{
title: 'Select',
slug: 'select',
diff --git a/packages/canon/src/components/FieldError/FieldError.stories.tsx b/packages/canon/src/components/FieldError/FieldError.stories.tsx
new file mode 100644
index 0000000000..39714d16d2
--- /dev/null
+++ b/packages/canon/src/components/FieldError/FieldError.stories.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 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.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import { TextField, Input, Form } from 'react-aria-components';
+import { FieldError } from './FieldError';
+
+const meta = {
+ title: 'Forms/FieldError',
+ component: FieldError,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// Show error with server validation using Form component
+export const WithServerValidation: Story = {
+ render: () => (
+
+ ),
+};
+
+// Show error using children
+export const WithCustomMessage: Story = {
+ render: () => (
+
+
+ This is a custom error message.
+
+ ),
+};
+
+// Show error with render prop function
+export const WithRenderProp: Story = {
+ render: () => (
+ 'This field is invalid'}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ }}
+ >
+
+
+ {({ validationErrors }) =>
+ validationErrors.length > 0 ? validationErrors[0] : 'Field is invalid'
+ }
+
+
+ ),
+};
diff --git a/packages/canon/src/components/FieldError/FieldError.styles.css b/packages/canon/src/components/FieldError/FieldError.styles.css
new file mode 100644
index 0000000000..816eb3c35e
--- /dev/null
+++ b/packages/canon/src/components/FieldError/FieldError.styles.css
@@ -0,0 +1,6 @@
+.canon-FieldError {
+ color: var(--canon-fg-danger);
+ font-size: var(--canon-font-size-2);
+ font-weight: var(--canon-font-weight-regular);
+ margin-top: var(--canon-space-2);
+}
diff --git a/packages/canon/src/components/FieldError/FieldError.tsx b/packages/canon/src/components/FieldError/FieldError.tsx
new file mode 100644
index 0000000000..b90d7b3bd3
--- /dev/null
+++ b/packages/canon/src/components/FieldError/FieldError.tsx
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+import { forwardRef } from 'react';
+import {
+ FieldError as AriaFieldError,
+ type FieldErrorProps,
+} from 'react-aria-components';
+import clsx from 'clsx';
+
+/** @public */
+export const FieldError = forwardRef(
+ (props: FieldErrorProps, ref) => {
+ const { className, ...rest } = props;
+
+ return (
+
+ );
+ },
+);
+
+FieldError.displayName = 'FieldError';
diff --git a/packages/canon/src/components/FieldError/index.ts b/packages/canon/src/components/FieldError/index.ts
new file mode 100644
index 0000000000..5b61f83ace
--- /dev/null
+++ b/packages/canon/src/components/FieldError/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 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.
+ */
+
+export * from './FieldError';
diff --git a/packages/canon/src/components/RadioGroup/RadioGroup.tsx b/packages/canon/src/components/RadioGroup/RadioGroup.tsx
index 5bdfcb8a20..d1d7451a92 100644
--- a/packages/canon/src/components/RadioGroup/RadioGroup.tsx
+++ b/packages/canon/src/components/RadioGroup/RadioGroup.tsx
@@ -18,10 +18,10 @@ import { forwardRef, useEffect } from 'react';
import {
RadioGroup as AriaRadioGroup,
Radio as AriaRadio,
- FieldError,
} from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
+import { FieldError } from '../FieldError';
import type { RadioGroupProps, RadioProps } from './types';
@@ -66,7 +66,7 @@ export const RadioGroup = forwardRef(
description={description}
/>