diff --git a/packages/ui/README.md b/packages/ui/README.md
index f22f7a3a076c02..d902e18560e75c 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -96,6 +96,112 @@ function MyComponent() {
}
```
+### Controlled and Uncontrolled Modes
+
+Interactive components that manage internal state (such as open/closed, selected value, etc.) follow a consistent prop naming convention that supports both **controlled** and **uncontrolled** usage.
+
+#### Prop naming pattern
+
+For a given state `x`, the convention is:
+
+| Prop | Purpose |
+| --- | --- |
+| `defaultX` | Sets the initial value in **uncontrolled** mode. The component manages subsequent state changes internally. |
+| `x` | Sets the current value in **controlled** mode. The consumer is responsible for updating the value in response to changes. |
+| `onXChange` | Callback invoked when the state changes. Receives the new value as its first argument. Works in both controlled and uncontrolled modes. |
+
+For example, a component with an open/closed state would expose:
+
+- `defaultOpen` — initial open state (uncontrolled)
+- `open` — current open state (controlled)
+- `onOpenChange` — called when the open state changes
+
+And a component with a selectable value would expose:
+
+- `defaultValue` — initial value (uncontrolled)
+- `value` — current value (controlled)
+- `onValueChange` — called when the value changes
+
+#### Uncontrolled usage
+
+In uncontrolled mode, the component manages its own state. Use `defaultX` to set the initial value, and optionally `onXChange` to react to changes:
+
+```tsx
+import { Tabs } from '@wordpress/ui';
+
+function MyTabs() {
+ return (
+ console.log( value ) }
+ >
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+
+ );
+}
+```
+
+#### Controlled usage
+
+In controlled mode, the consumer owns the state and passes it via `x`. State changes are handled through `onXChange`:
+
+```tsx
+import { useState } from '@wordpress/element';
+import { CollapsibleCard } from '@wordpress/ui';
+
+function MyCard() {
+ const [ isOpen, setIsOpen ] = useState( false );
+
+ return (
+
+ Details
+
+ Collapsible content here.
+
+
+ );
+}
+```
+
+When both `x` and `defaultX` are provided, `x` takes precedence and the component behaves in controlled mode. When neither is provided, the component uses its own internal default (typically documented via a `@default` JSDoc tag on the `defaultX` prop).
+
+#### Difference from native `onChange`
+
+The `onXChange` callback is distinct from the native DOM `onChange` event handler. Native `onChange` fires a `React.ChangeEvent` tied to a specific DOM element, while `onXChange` provides the new **value** directly — making it simpler to use and consistent across all components, including compound and non-form components.
+
+Components that wrap native form elements may still support native event handlers (like `onChange`, `onInput`) for interoperability, but `onXChange` is the recommended approach within this package.
+
+#### Guidelines for component authors
+
+When designing props for a new component:
+
+- Always offer both controlled and uncontrolled modes when the component has user-facing state.
+- Name the uncontrolled prop `defaultX`, the controlled prop `x`, and the callback `onXChange`.
+- In JSDoc comments, indicate which mode each prop is for and cross-reference the alternative:
+ ```ts
+ /**
+ * Whether the panel is currently open (controlled).
+ *
+ * To render an uncontrolled component, use the `defaultOpen` prop instead.
+ */
+ open?: boolean;
+ /**
+ * Whether the panel is initially open (uncontrolled).
+ * @default false
+ */
+ defaultOpen?: boolean;
+ /**
+ * Event handler called when the open state changes.
+ */
+ onOpenChange?: ( open: boolean ) => void;
+ ```
+- Provide a `@default` JSDoc tag for the uncontrolled prop when there is a sensible default.
+
## Contributing to this package
This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.