diff --git a/.changeset/composable-group-legend.md b/.changeset/composable-group-legend.md
new file mode 100644
index 0000000000..f5fed37631
--- /dev/null
+++ b/.changeset/composable-group-legend.md
@@ -0,0 +1,11 @@
+---
+"@cloudflare/kumo": minor
+---
+
+feat(radio, checkbox, switch): add composable Legend sub-component for group components
+
+- Add `Radio.Legend`, `Checkbox.Legend`, and `Switch.Legend` sub-components
+- Accepts `className` for full styling control (e.g. `className="sr-only"` to visually hide)
+- Make `legend` string prop optional when using the sub-component instead
+- Useful when a parent Field already provides a visible label and the legend would be redundant
+- **Breaking:** `Switch.Group` no longer renders a visible border/padding/rounded container — now consistent with `Radio.Group` and `Checkbox.Group`. Use `className` to add a border if needed.
diff --git a/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx b/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx
index ec4028460c..b50844c2d6 100644
--- a/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx
@@ -78,6 +78,36 @@ export function CheckboxGroupDemo() {
);
}
+/** Shows Checkbox.Legend with sr-only to visually hide the legend while keeping it accessible, useful when a parent Field already provides a visible label */
+export function CheckboxLegendSrOnlyDemo() {
+ const [preferences, setPreferences] = useState(["email"]);
+ return (
+
+
+ Notification preferences
+
+
+
+
+
+ );
+}
+
+/** Shows Checkbox.Legend with custom styling for full control over legend presentation */
+export function CheckboxLegendCustomDemo() {
+ const [preferences, setPreferences] = useState(["email"]);
+ return (
+
+
+ Notification preferences
+
+
+
+
+
+ );
+}
+
export function CheckboxGroupErrorDemo() {
return (
+ Paths
+
+
+
+ );
+}
+
+/** Shows Radio.Legend with custom styling for full control over legend presentation */
+export function RadioLegendCustomDemo() {
+ const [value, setValue] = useState("email");
+ return (
+
+
+ Notification preference
+
+
+
+
+
+ );
+}
+
/** Shows radio card appearance in horizontal layout */
export function RadioCardHorizontalDemo() {
const [value, setValue] = useState("free");
diff --git a/packages/kumo-docs-astro/src/components/demos/SwitchDemo.tsx b/packages/kumo-docs-astro/src/components/demos/SwitchDemo.tsx
index 66ba25d05d..37cf83ef05 100644
--- a/packages/kumo-docs-astro/src/components/demos/SwitchDemo.tsx
+++ b/packages/kumo-docs-astro/src/components/demos/SwitchDemo.tsx
@@ -94,6 +94,43 @@ export function SwitchCustomIdDemo() {
);
}
+/** Shows a Switch.Group with a legend for grouping related switches */
+export function SwitchGroupDemo() {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Shows Switch.Legend with sr-only to visually hide the legend while keeping it accessible, useful when a parent Field already provides a visible label */
+export function SwitchLegendSrOnlyDemo() {
+ return (
+
+ Notification settings
+
+
+
+
+ );
+}
+
+/** Shows Switch.Legend with custom styling for full control over legend presentation */
+export function SwitchLegendCustomDemo() {
+ return (
+
+
+ Notification settings
+
+
+
+
+
+ );
+}
+
/** All sizes comparison */
export function SwitchSizesDemo() {
return (
diff --git a/packages/kumo-docs-astro/src/pages/components/checkbox.mdx b/packages/kumo-docs-astro/src/pages/components/checkbox.mdx
index 2e80376b7f..a0065e323f 100644
--- a/packages/kumo-docs-astro/src/pages/components/checkbox.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/checkbox.mdx
@@ -20,6 +20,8 @@ import {
CheckboxErrorDemo,
CheckboxGroupDemo,
CheckboxGroupErrorDemo,
+ CheckboxLegendSrOnlyDemo,
+ CheckboxLegendCustomDemo,
} from "~/components/demos/CheckboxDemo";
{/* Hero Demo */}
@@ -38,7 +40,7 @@ import {
### Barrel
-
+
### Granular
@@ -128,13 +130,37 @@ export default function Example() {
### Checkbox Group with Error
-
- Show validation errors at the group level. Error replaces description
- when present.
-
-
-
-
+
+ Show validation errors at the group level. Error replaces description when
+ present.
+
+
+
+
+
+### Visually Hidden Legend
+
+
+ Use `Checkbox.Legend` with `className="sr-only"` to keep the legend accessible
+ to screen readers while hiding it visually. This is useful when the group is
+ already labeled by a parent `Field` or heading, and showing the legend would
+ create a redundant label.
+
+
+
+
+
+### Custom Legend Styling
+
+
+ `Checkbox.Legend` accepts `className` for full control over legend
+ presentation. Use it instead of the `legend` string prop when you need custom
+ typography, colors, or layout.
+
+
+
+
+
{/* API Reference */}
@@ -155,6 +181,15 @@ export default function Example() {
+### Checkbox.Legend
+
+
+ Composable legend sub-component for Checkbox.Group. Accepts `className` for
+ full styling control (e.g. `className="sr-only"` to visually hide). Use
+ instead of the `legend` string prop when you need custom legend styling.
+
+
+
### Checkbox.Item
Individual checkbox within Checkbox.Group.
@@ -197,5 +232,6 @@ export default function Example() {
proper grouping announcement.
+
diff --git a/packages/kumo-docs-astro/src/pages/components/radio.mdx b/packages/kumo-docs-astro/src/pages/components/radio.mdx
index bc54fc877f..3a28f2a855 100644
--- a/packages/kumo-docs-astro/src/pages/components/radio.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/radio.mdx
@@ -19,6 +19,8 @@ import {
RadioControlPositionDemo,
RadioCardDemo,
RadioCardHorizontalDemo,
+ RadioLegendSrOnlyDemo,
+ RadioLegendCustomDemo,
} from "~/components/demos/RadioDemo";
{/* Hero Demo */}
@@ -144,6 +146,29 @@ export default function Example() {
+### Visually Hidden Legend
+
+
+ Use `Radio.Legend` with `className="sr-only"` to keep the legend accessible to
+ screen readers while hiding it visually. This is useful when the radio group
+ is already labeled by a parent `Field` or heading, and showing the legend
+ would create a redundant label.
+
+
+
+
+
+### Custom Legend Styling
+
+
+ `Radio.Legend` accepts `className` for full control over legend presentation.
+ Use it instead of the `legend` string prop when you need custom typography,
+ colors, or layout.
+
+
+
+
+
{/* API Reference */}
@@ -157,6 +182,15 @@ export default function Example() {
Container for radio buttons with legend, description, and error support.
+### Radio.Legend
+
+
+ Composable legend sub-component for Radio.Group. Accepts `className` for full
+ styling control (e.g. `className="sr-only"` to visually hide). Use instead of
+ the `legend` string prop when you need custom legend styling.
+
+
+
### Radio.Item
Individual radio button within Radio.Group.
@@ -195,5 +229,6 @@ export default function Example() {
Each radio is announced with its label and selection state. The group legend provides context for all options.
+
diff --git a/packages/kumo-docs-astro/src/pages/components/switch.mdx b/packages/kumo-docs-astro/src/pages/components/switch.mdx
index 7e35f20ef0..bd1f5b49f0 100644
--- a/packages/kumo-docs-astro/src/pages/components/switch.mdx
+++ b/packages/kumo-docs-astro/src/pages/components/switch.mdx
@@ -19,6 +19,9 @@ import {
SwitchVariantsDemo,
SwitchSizesDemo,
SwitchCustomIdDemo,
+ SwitchGroupDemo,
+ SwitchLegendSrOnlyDemo,
+ SwitchLegendCustomDemo,
} from "~/components/demos/SwitchDemo";
{/* Hero Demo */}
@@ -137,6 +140,39 @@ export default function Example() {
+### Switch Group
+
+
+ Group related switches with `Switch.Group`. Provides a shared legend,
+ description, and error message for the group.
+
+
+
+
+
+### Visually Hidden Legend
+
+
+ Use `Switch.Legend` with `className="sr-only"` to keep the legend accessible
+ to screen readers while hiding it visually. This is useful when the group is
+ already labeled by a parent `Field` or heading, and showing the legend would
+ create a redundant label.
+
+
+
+
+
+### Custom Legend Styling
+
+
+ `Switch.Legend` accepts `className` for full control over legend presentation.
+ Use it instead of the `legend` string prop when you need custom typography,
+ colors, or layout.
+
+
+
+
+
{/* API Reference */}
@@ -145,5 +181,30 @@ export default function Example() {
## API Reference
-
+### Switch
+
+Individual switch toggle with built-in label.
+
+
+### Switch.Group
+
+
+ Container for multiple switches with legend, description, and error support.
+
+
+
+### Switch.Legend
+
+
+ Composable legend sub-component for Switch.Group. Accepts `className` for full
+ styling control (e.g. `className="sr-only"` to visually hide). Use instead of
+ the `legend` string prop when you need custom legend styling.
+
+
+
+### Switch.Item
+
+Individual switch within Switch.Group.
+
+
diff --git a/packages/kumo/src/components/checkbox/checkbox.tsx b/packages/kumo/src/components/checkbox/checkbox.tsx
index 27838c08c7..5d2f723514 100644
--- a/packages/kumo/src/components/checkbox/checkbox.tsx
+++ b/packages/kumo/src/components/checkbox/checkbox.tsx
@@ -150,10 +150,34 @@ export type CheckboxProps = {
*
* ```
*/
+/**
+ * Props for Checkbox.Legend — a composable sub-component for labeling a Checkbox.Group.
+ *
+ * Place as a direct child of `` to provide a styled, accessible legend.
+ * Accepts `className` for full styling control (e.g. `className="sr-only"` to visually hide).
+ *
+ * @example
+ * ```tsx
+ *
+ * Preferences
+ *
+ *
+ * ```
+ */
+export interface CheckboxLegendProps {
+ /** Legend content */
+ children: ReactNode;
+ /** Additional CSS classes (e.g. "sr-only" to visually hide the legend) */
+ className?: string;
+}
+
export interface CheckboxGroupProps {
- /** Legend text for the group */
- legend: string;
- /** Child Checkbox.Item components */
+ /**
+ * Legend text for the group.
+ * For more control over legend styling, omit this prop and use `` as a child instead.
+ */
+ legend?: string;
+ /** Child Checkbox.Item components (and optionally a Checkbox.Legend) */
children: ReactNode;
/** Error message for the group (only appears in groups, not single checkboxes) */
error?: string;
@@ -263,7 +287,8 @@ const CheckboxBase = forwardRef(
className={cn(
"relative flex h-4 w-4 items-center justify-center rounded-sm border-0 bg-kumo-base ring after:absolute after:-inset-x-3 after:-inset-y-2",
variant === "error" ? "ring-kumo-danger" : "ring-kumo-hairline",
- !disabled && "hover:ring-kumo-hairline focus-visible:ring-kumo-hairline",
+ !disabled &&
+ "hover:ring-kumo-hairline focus-visible:ring-kumo-hairline",
"data-[checked]:bg-kumo-contrast data-[checked]:ring-kumo-contrast data-[indeterminate]:bg-kumo-contrast data-[indeterminate]:ring-kumo-contrast",
disabled && "cursor-not-allowed opacity-50",
className,
@@ -392,6 +417,19 @@ const CheckboxItem = forwardRef(
CheckboxItem.displayName = "Checkbox.Item";
+// Checkbox.Legend — composable legend sub-component for Checkbox.Group
+function CheckboxLegend({ children, className }: CheckboxLegendProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+CheckboxLegend.displayName = "Checkbox.Legend";
+
// Checkbox.Group with built-in Fieldset and CheckboxGroup
function CheckboxGroup({
legend,
@@ -416,9 +454,11 @@ function CheckboxGroup({
disabled={disabled}
>
-
- {legend}
-
+ {legend && (
+
+ {legend}
+
+ )}
{children}
{error && {error}
}
{description && (
@@ -434,6 +474,7 @@ function CheckboxGroup({
export const Checkbox = Object.assign(CheckboxBase, {
Item: CheckboxItem,
Group: CheckboxGroup,
+ Legend: CheckboxLegend,
});
Checkbox.displayName = "Checkbox";
diff --git a/packages/kumo/src/components/checkbox/index.ts b/packages/kumo/src/components/checkbox/index.ts
index a50d03db50..ea1587b2de 100644
--- a/packages/kumo/src/components/checkbox/index.ts
+++ b/packages/kumo/src/components/checkbox/index.ts
@@ -3,6 +3,7 @@ export {
KUMO_CHECKBOX_VARIANTS,
KUMO_CHECKBOX_DEFAULT_VARIANTS,
type CheckboxProps,
+ type CheckboxLegendProps,
type CheckboxGroupProps,
type CheckboxItemProps,
type KumoCheckboxVariant,
diff --git a/packages/kumo/src/components/radio/index.ts b/packages/kumo/src/components/radio/index.ts
index f1b5eb8149..b5eb847dd4 100644
--- a/packages/kumo/src/components/radio/index.ts
+++ b/packages/kumo/src/components/radio/index.ts
@@ -5,6 +5,7 @@ export {
KUMO_RADIO_DEFAULT_VARIANTS,
radioVariants,
type RadioGroupProps,
+ type RadioLegendProps,
type RadioItemProps,
type RadioControlPosition,
type KumoRadioVariant,
diff --git a/packages/kumo/src/components/radio/radio.tsx b/packages/kumo/src/components/radio/radio.tsx
index 9e3b79e92a..8d41565dca 100644
--- a/packages/kumo/src/components/radio/radio.tsx
+++ b/packages/kumo/src/components/radio/radio.tsx
@@ -136,10 +136,34 @@ const RadioGroupContext = createContext<{
*
* ```
*/
+/**
+ * Props for Radio.Legend — a composable sub-component for labeling a Radio.Group.
+ *
+ * Place as a direct child of `` to provide a styled, accessible legend.
+ * Accepts `className` for full styling control (e.g. `className="sr-only"` to visually hide).
+ *
+ * @example
+ * ```tsx
+ *
+ * Paths
+ *
+ *
+ * ```
+ */
+export interface RadioLegendProps {
+ /** Legend content */
+ children: ReactNode;
+ /** Additional CSS classes (e.g. "sr-only" to visually hide the legend) */
+ className?: string;
+}
+
export interface RadioGroupProps {
- /** Legend text for the group (required for accessibility) */
- legend: string;
- /** Child Radio.Item components */
+ /**
+ * Legend text for the group (required for accessibility).
+ * For more control over legend styling, omit this prop and use `` as a child instead.
+ */
+ legend?: string;
+ /** Child Radio.Item components (and optionally a Radio.Legend) */
children: ReactNode;
/** Layout direction of the radio items */
orientation?: "vertical" | "horizontal";
@@ -326,6 +350,19 @@ const RadioItem = forwardRef(
RadioItem.displayName = "Radio.Item";
+// Radio.Legend — composable legend sub-component for Radio.Group
+function RadioLegend({ children, className }: RadioLegendProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+RadioLegend.displayName = "Radio.Legend";
+
// Radio.Group with built-in Fieldset and RadioGroup
function RadioGroup({
legend,
@@ -355,9 +392,11 @@ function RadioGroup({
disabled={disabled}
className={cn("flex flex-col gap-4", className)}
>
-
- {legend}
-
+ {legend && (
+
+ {legend}
+
+ )}
*
*
*
*
+ *
+ * // Composable: Radio.Legend for full styling control (e.g. visually hidden)
+ *
+ * Notification preference
+ *
+ *
+ *
* ```
*/
export const Radio = Object.assign(RadioGroup, {
Item: RadioItem,
Group: RadioGroup,
+ Legend: RadioLegend,
});
diff --git a/packages/kumo/src/components/switch/index.ts b/packages/kumo/src/components/switch/index.ts
index 358e6454d7..26c5639f8b 100644
--- a/packages/kumo/src/components/switch/index.ts
+++ b/packages/kumo/src/components/switch/index.ts
@@ -3,6 +3,7 @@ export {
KUMO_SWITCH_VARIANTS,
KUMO_SWITCH_DEFAULT_VARIANTS,
type SwitchProps,
+ type SwitchLegendProps,
type SwitchGroupProps,
type SwitchItemProps,
type KumoSwitchSize,
diff --git a/packages/kumo/src/components/switch/switch.tsx b/packages/kumo/src/components/switch/switch.tsx
index 388bc64e06..41af222b63 100644
--- a/packages/kumo/src/components/switch/switch.tsx
+++ b/packages/kumo/src/components/switch/switch.tsx
@@ -149,10 +149,34 @@ export type SwitchProps = Omit<
*
* ```
*/
+/**
+ * Props for Switch.Legend — a composable sub-component for labeling a Switch.Group.
+ *
+ * Place as a direct child of `
` to provide a styled, accessible legend.
+ * Accepts `className` for full styling control (e.g. `className="sr-only"` to visually hide).
+ *
+ * @example
+ * ```tsx
+ *
+ * Notification settings
+ *
+ *
+ * ```
+ */
+export interface SwitchLegendProps {
+ /** Legend content */
+ children: ReactNode;
+ /** Additional CSS classes (e.g. "sr-only" to visually hide the legend) */
+ className?: string;
+}
+
export interface SwitchGroupProps {
- /** Legend text for the group */
- legend: string;
- /** Child Switch.Item components */
+ /**
+ * Legend text for the group.
+ * For more control over legend styling, omit this prop and use `` as a child instead.
+ */
+ legend?: string;
+ /** Child Switch.Item components (and optionally a Switch.Legend) */
children: ReactNode;
/** Error message for the group (only appears in groups, not single switches) */
error?: string;
@@ -460,6 +484,19 @@ const SwitchItem = forwardRef(
SwitchItem.displayName = "Switch.Item";
+// Switch.Legend — composable legend sub-component for Switch.Group
+function SwitchLegend({ children, className }: SwitchLegendProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+SwitchLegend.displayName = "Switch.Legend";
+
// Switch.Group with built-in Fieldset
function SwitchGroup({
legend,
@@ -473,15 +510,14 @@ function SwitchGroup({
return (
-
- {legend}
-
+ {legend && (
+
+ {legend}
+
+ )}
{children}
{error && {error}
}
{description && (
@@ -496,6 +532,7 @@ function SwitchGroup({
export const Switch = Object.assign(SwitchBase, {
Item: SwitchItem,
Group: SwitchGroup,
+ Legend: SwitchLegend,
});
Switch.displayName = "Switch";
diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts
index d3f0b66268..a0e046d216 100644
--- a/packages/kumo/src/index.ts
+++ b/packages/kumo/src/index.ts
@@ -43,7 +43,11 @@ export {
* @deprecated Use {@link DatePicker} with `mode="range"` instead.
*/
export { DateRangePicker } from "./components/date-range-picker";
-export { Checkbox, type CheckboxProps } from "./components/checkbox";
+export {
+ Checkbox,
+ type CheckboxProps,
+ type CheckboxLegendProps,
+} from "./components/checkbox";
export { ClipboardText } from "./components/clipboard-text";
export { Code, CodeBlock } from "./components/code";
export { Combobox } from "./components/combobox";
@@ -104,7 +108,7 @@ export { Select } from "./components/select";
* @deprecated Use {@link LayerCard} instead.
*/
export { Surface } from "./components/surface";
-export { Switch } from "./components/switch";
+export { Switch, type SwitchLegendProps } from "./components/switch";
export { Tabs, type TabsProps, type TabsItem } from "./components/tabs";
export { Table } from "./components/table";
export { Text } from "./components/text";
@@ -139,6 +143,7 @@ export {
KUMO_RADIO_DEFAULT_VARIANTS,
radioVariants,
type RadioGroupProps,
+ type RadioLegendProps,
type RadioItemProps,
type RadioControlPosition,
type KumoRadioVariant,