diff --git a/.changeset/add-info-dialog-pattern.md b/.changeset/add-info-dialog-pattern.md
new file mode 100644
index 000000000..56c5e0ab6
--- /dev/null
+++ b/.changeset/add-info-dialog-pattern.md
@@ -0,0 +1,5 @@
+---
+"@commercetools/nimbus": minor
+---
+
+feat(info-dialog): add InfoDialog pattern (FEC-437)
diff --git a/bundle-sizes.json b/bundle-sizes.json
index 10f082bb3..1f577a285 100644
--- a/bundle-sizes.json
+++ b/bundle-sizes.json
@@ -1,7 +1,7 @@
{
"@commercetools/nimbus": {
- "esm": 1547139,
- "cjs": 60942
+ "esm": 1588197,
+ "cjs": 64416
},
"@commercetools/nimbus-icons": {
"esm": 1555358,
diff --git a/openspec/changes/info-dialog-pattern/design.md b/openspec/changes/info-dialog-pattern/design.md
new file mode 100644
index 000000000..b8da12d97
--- /dev/null
+++ b/openspec/changes/info-dialog-pattern/design.md
@@ -0,0 +1,110 @@
+## Context
+
+The Merchant Center Application Kit's `InfoDialog` has accrued configuration
+props over time (`size`, `zIndex`, `aria-label`, `getParentSelector`, a
+`TextTitle` sub-component). A usage audit across every MC repository shows
+that most of this surface area is unused in practice:
+
+| Prop / surface | Usage across ~131 production instances |
+| --- | --- |
+| `size` (default) | 83% (94 files) |
+| `size` (any explicit value) | 17% (19 files, dominated by `size={16}`) |
+| `title` as string | 96% (126 files) |
+| `title` as ReactNode | 4% (5 files, mostly badge/icon + heading) |
+| `TextTitle` sub-component | 0% (zero consumer usages across 10 repos) |
+| `zIndex` | Rare; handled by Dialog primitive |
+| `getParentSelector` | Rare; handled by React Aria portaling |
+
+Nimbus's `Dialog` primitive already solves the cross-cutting concerns via
+its recipe (z-index stacking with
+`calc(var(--dialog-z-index) + var(--layer-index, 0))`) and React Aria's
+`Modal` + `ModalOverlay` (portaling). That means the pattern layer can
+stay thin.
+
+## Goals / Non-Goals
+
+### Goals
+
+- Ship a low-friction API for the overwhelmingly common read-only
+ informational dialog shape
+- Maintain feature parity for the >95% case (string or composed-JSX title,
+ default size, default dismiss behaviour)
+- Delegate all modal mechanics (z-index, portal target, focus trap,
+ dismissal wiring) to the underlying `Dialog`
+
+### Non-Goals
+
+- Configurable sizing at the pattern layer — consumers needing a
+ non-default size drop down to `Dialog` directly (documented escape hatch)
+- `TextTitle` sub-component — zero consumer uptake in app-kit, drop
+ entirely
+- Duplicating `aria-label` enforcement at the component boundary — a11y
+ linting in consumer repositories is the intended layer for catching
+ missing accessible names on non-string titles
+
+## Decisions
+
+### Decision: Location under `patterns/dialogs/`
+
+Place the component at `packages/nimbus/src/patterns/dialogs/info-dialog/`,
+introducing a new `dialogs/` sub-category next to the existing `fields/`
+sub-category.
+
+- **Alternatives considered**:
+ - `components/info-dialog/` (alongside the `Dialog` primitive) —
+ rejected because the primitive vs. pattern distinction is the key
+ mental model: primitive = building block, pattern = pre-configured
+ composition.
+ - `patterns/info-dialog/` (flat, no sub-category) — rejected because
+ future dialog patterns (`FormDialog`, `ConfirmationDialog`) will need
+ a home, and grouping them under `dialogs/` mirrors the app-kit
+ structure consumers are already familiar with.
+- **Rationale**: InfoDialog is not a new primitive, it is a canonical
+ composition of Dialog parts. The `patterns/` directory is the right
+ home, and the `dialogs/` sub-category prepares for sibling patterns
+ expected under the same migration epic.
+
+### Decision: `title` is `ReactNode`, not `string`
+
+Five concrete app-kit usages (identity SSO, discount-group info dialog,
+risks-stepper, and two discount-description dialogs) pass composed JSX
+(badge + heading, icon + heading). Keeping the prop as `ReactNode` avoids
+a regression for these consumers and keeps the API symmetric with other
+Nimbus components.
+
+### Decision: No `aria-label` prop
+
+`Dialog.Root` already accepts `aria-label` via its own API. Consumers
+passing a non-string `title` who need an explicit accessible label can
+compose `Dialog` directly (escape hatch). Avoiding duplication at the
+pattern layer keeps the flat API flat; accessibility linting in consumer
+repos catches missing labels when they matter.
+
+### Decision: Dismiss behaviour defaults on
+
+Forward `isDismissable` as enabled to `Dialog.Root` so clicking the
+overlay or pressing Escape closes the dialog. This matches app-kit's
+behaviour when `onClose` is provided and aligns with user expectations
+for a read-only dialog with no forms to lose.
+
+## Risks / Trade-offs
+
+- **Risk**: The 19 app-kit consumers who set an explicit `size` will need
+ to migrate to `Dialog` directly when the time comes.
+ **Mitigation**: Document the escape hatch prominently with a runnable
+ code sample in `.dev.mdx`; call it out in the migration notes when this
+ pattern is rolled out.
+
+- **Trade-off**: Dropping `TextTitle` means any consumer who later wants
+ themed title styling must compose `Dialog` directly. Given zero current
+ usage, the simplicity of the flat API wins. If demand emerges, we can
+ revisit without breaking existing consumers.
+
+## Open Questions
+
+- **Does the pattern need a `.i18n.ts` file?** The only user-facing
+ string the pattern itself owns is the close-button accessible name,
+ which is already provided by `Dialog.CloseTrigger`'s default
+ `aria-label="Close dialog"`. Existing patterns (e.g.
+ `text-input-field`) ship without `.i18n.ts`. Current expectation:
+ no `.i18n.ts` required; confirm during implementation.
diff --git a/openspec/changes/info-dialog-pattern/proposal.md b/openspec/changes/info-dialog-pattern/proposal.md
new file mode 100644
index 000000000..37fdf2145
--- /dev/null
+++ b/openspec/changes/info-dialog-pattern/proposal.md
@@ -0,0 +1,61 @@
+# Change: Add InfoDialog Pattern Component
+
+## Why
+
+Consumers migrating from Merchant Center Application Kit need a Nimbus
+replacement for the `InfoDialog` component. It is the most common read-only
+informational dialog pattern in the MC codebase (~131 production usages).
+Rather than require each consumer to compose `Dialog.Root`, `Dialog.Content`,
+`Dialog.Header`, `Dialog.Title`, `Dialog.Body`, and `Dialog.CloseTrigger`
+every time, Nimbus provides `InfoDialog` as a pre-configured pattern with a
+flat, minimal API.
+
+A usage audit of the app-kit component across every MC repository informs the
+scope: 83% of usages take the default size, 96% pass a string title, and the
+`TextTitle` sub-component has zero consumer uptake — so the Nimbus pattern
+can drop the app-kit `size`, `zIndex`, `getParentSelector`, and `TextTitle`
+props without regressing real-world usage. `aria-label` is retained as an
+optional escape for composed `ReactNode` titles whose auto-derived accessible
+name would be confusing.
+
+## What Changes
+
+- **NEW** `InfoDialog` pattern at
+ `packages/nimbus/src/patterns/dialogs/info-dialog/` — introduces a new
+ `dialogs/` sub-category under `patterns/` alongside the existing
+ `fields/` sub-category
+- **NEW** Flat props API:
+ - `title: ReactNode`
+ - `children: ReactNode`
+ - `isOpen?: boolean` (controlled mode)
+ - `defaultOpen?: boolean` (uncontrolled mode)
+ - `onOpenChange?: (isOpen: boolean) => void`
+ - `aria-label?: string` (overrides accessible name derived from `title`)
+- **NEW** Internally composes `Dialog.Root`, `Dialog.Content`,
+ `Dialog.Header`, `Dialog.Title`, `Dialog.Body`, `Dialog.CloseTrigger`;
+ no footer actions
+- **NEW** Close affordances: X button in header, Escape key, overlay click
+- **NEW** Stacking, sizing defaults, and portaling delegated entirely to
+ the underlying Dialog primitive — no `size`, `zIndex`, or portal props
+- **NEW** Hardcodes `scrollBehavior="inside"` on the underlying Dialog so
+ long content scrolls within the body while the header stays pinned at
+ the top
+- **NEW** `.dev.mdx` documentation includes an "escape hatch" section
+ showing the equivalent manual Dialog composition for consumers needing
+ custom size or dismiss behaviour
+- **MODIFIED** `packages/nimbus/src/patterns/index.ts` adds new
+ `./dialogs` export
+
+## Impact
+
+- Affected specs: none (new capability `nimbus-info-dialog`)
+- Affected code:
+ - `packages/nimbus/src/patterns/dialogs/info-dialog/` (new)
+ - `packages/nimbus/src/patterns/dialogs/index.ts` (new)
+ - `packages/nimbus/src/patterns/index.ts` (export added)
+
+## Related
+
+- Jira: FEC-437
+- Parent epic: FEC-428 (Application-Components Migration to Nimbus)
+- Replaces: `merchant-center-application-kit` `InfoDialog` component
diff --git a/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md b/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md
new file mode 100644
index 000000000..71125ad9d
--- /dev/null
+++ b/openspec/changes/info-dialog-pattern/specs/nimbus-info-dialog/spec.md
@@ -0,0 +1,207 @@
+## ADDED Requirements
+
+### Requirement: Component Export
+
+The InfoDialog pattern SHALL export as a single flat component (not a
+compound namespace) from `@commercetools/nimbus`.
+
+#### Scenario: Import surface
+
+- **WHEN** a consumer imports from `@commercetools/nimbus`
+- **THEN** SHALL expose an `InfoDialog` component
+- **AND** SHALL expose an `InfoDialogProps` type
+
+#### Scenario: Location
+
+- **WHEN** the source is inspected
+- **THEN** the component SHALL reside at
+ `packages/nimbus/src/patterns/dialogs/info-dialog/`
+- **AND** SHALL be re-exported through
+ `packages/nimbus/src/patterns/dialogs/index.ts`
+- **AND** that file SHALL be re-exported through
+ `packages/nimbus/src/patterns/index.ts`
+
+### Requirement: Flat Props API
+
+The InfoDialog component SHALL accept a small, flat set of props that
+together cover the common informational-dialog case.
+
+#### Scenario: Required props
+
+- **WHEN** InfoDialog is rendered
+- **THEN** SHALL require `title` typed as `ReactNode`
+- **AND** SHALL require `children` typed as `ReactNode`
+
+#### Scenario: Optional props
+
+- **WHEN** InfoDialog is rendered
+- **THEN** MAY accept `isOpen` typed as `boolean` for controlled usage
+- **AND** MAY accept `defaultOpen` typed as `boolean` for uncontrolled usage
+- **AND** MAY accept `onOpenChange` typed as `(isOpen: boolean) => void`
+- **AND** MAY accept `aria-label` typed as `string` to override the
+ accessible name derived from `title`
+
+#### Scenario: No additional configuration props
+
+- **WHEN** the `InfoDialogProps` type is inspected
+- **THEN** SHALL NOT declare a `size` prop
+- **AND** SHALL NOT declare a `zIndex` prop
+- **AND** SHALL NOT declare a `getParentSelector` or other portal prop
+- **AND** SHALL NOT expose a `TextTitle` sub-component
+
+### Requirement: Internal Composition
+
+The InfoDialog SHALL be implemented by composing the Nimbus `Dialog`
+primitive rather than duplicating its logic.
+
+#### Scenario: Composed parts
+
+- **WHEN** InfoDialog renders
+- **THEN** SHALL wrap the output in `Dialog.Root`
+- **AND** SHALL use `Dialog.Content` for the dialog surface
+- **AND** SHALL use `Dialog.Header` containing `Dialog.Title` for the
+ heading area
+- **AND** SHALL use `Dialog.CloseTrigger` for the close button in the
+ header
+- **AND** SHALL use `Dialog.Body` for the content area
+- **AND** SHALL NOT render a `Dialog.Footer`
+
+#### Scenario: Prop forwarding
+
+- **WHEN** InfoDialog receives `isOpen`, `defaultOpen`, `onOpenChange`,
+ or `aria-label`
+- **THEN** SHALL forward those values to `Dialog.Root`
+
+### Requirement: Open State
+
+The InfoDialog SHALL reflect its open state through the underlying Dialog
+primitive and support both controlled and uncontrolled usage.
+
+#### Scenario: Controlled open
+
+- **WHEN** `isOpen` is `true`
+- **THEN** the dialog SHALL be visible
+
+#### Scenario: Uncontrolled open
+
+- **WHEN** `isOpen` is omitted and `defaultOpen` is `true`
+- **THEN** the dialog SHALL be visible on mount
+- **AND** SHALL close when the user triggers any close affordance without
+ requiring the consumer to manage state
+
+#### Scenario: Controlled close
+
+- **WHEN** the user triggers any close affordance
+- **THEN** SHALL invoke `onOpenChange(false)`
+
+#### Scenario: Opening
+
+- **WHEN** `isOpen` transitions from `false` to `true`
+- **THEN** focus SHALL move into the dialog
+- **AND** focus SHALL be trapped within the dialog
+
+#### Scenario: Closing
+
+- **WHEN** `isOpen` transitions from `true` to `false`
+- **THEN** focus SHALL return to the element that was focused before the
+ dialog opened
+
+### Requirement: Close Affordances
+
+Because the InfoDialog has no footer actions, it SHALL close via multiple
+user actions.
+
+#### Scenario: Close button
+
+- **WHEN** the user clicks the close button in the header
+- **THEN** SHALL invoke `onOpenChange(false)`
+
+#### Scenario: Escape key
+
+- **WHEN** the user presses Escape while the dialog is open
+- **THEN** SHALL invoke `onOpenChange(false)`
+
+#### Scenario: Overlay click
+
+- **WHEN** the user clicks outside the dialog content (on the overlay)
+- **THEN** SHALL invoke `onOpenChange(false)`
+
+### Requirement: Title Rendering
+
+The InfoDialog SHALL render the `title` prop as the accessible heading
+of the dialog.
+
+#### Scenario: String title
+
+- **WHEN** `title` is a string
+- **THEN** SHALL render it inside `Dialog.Title`
+- **AND** SHALL expose the string as the accessible name of the dialog
+
+#### Scenario: ReactNode title
+
+- **WHEN** `title` is a React element (e.g. composed markup with an icon
+ or badge alongside heading text)
+- **THEN** SHALL render the element inside `Dialog.Title`
+
+### Requirement: Content Rendering
+
+The InfoDialog SHALL render `children` inside the dialog body with
+scrolling handled by the underlying Dialog primitive.
+
+#### Scenario: Short content
+
+- **WHEN** `children` fit within the dialog viewport
+- **THEN** SHALL render without a scrollbar
+
+#### Scenario: Long content
+
+- **WHEN** `children` exceed the available viewport height
+- **THEN** the body SHALL scroll
+- **AND** the header SHALL remain visible at the top of the dialog
+
+### Requirement: Accessibility
+
+By virtue of composing the Nimbus `Dialog` primitive, InfoDialog SHALL
+meet WCAG 2.1 AA dialog requirements.
+
+#### Scenario: Dialog role
+
+- **WHEN** the dialog is open
+- **THEN** SHALL expose `role="dialog"` to assistive technology
+
+#### Scenario: Accessible name
+
+- **WHEN** `title` is a string
+- **THEN** the dialog's accessible name SHALL be that string
+- **AND** no additional `aria-label` SHALL be required from the consumer
+
+#### Scenario: Accessible name override
+
+- **WHEN** `aria-label` is provided
+- **THEN** it SHALL be forwarded to `Dialog.Root` and SHALL serve as the
+ dialog's accessible name, overriding the name derived from `title`
+
+### Requirement: Developer Documentation
+
+The pattern SHALL ship with developer-facing documentation that covers
+both the flat API and an escape hatch for advanced customization.
+
+#### Scenario: Frontmatter
+
+- **WHEN** the `.mdx` file is read
+- **THEN** SHALL declare `related-components: [Dialog]`
+- **AND** SHALL declare `menu: [Patterns, Dialogs, Info dialog]`
+
+#### Scenario: Escape hatch section
+
+- **WHEN** the `.dev.mdx` file is rendered
+- **THEN** SHALL include a section demonstrating the equivalent manual
+ `Dialog` composition
+- **AND** SHALL guide consumers to drop down to `Dialog` when they need
+ a non-default size or a custom dismissability behaviour
+
+#### Scenario: API reference
+
+- **WHEN** the `.dev.mdx` renders the PropsTable
+- **THEN** SHALL list the public props (`title`, `children`, `isOpen`,
+ `defaultOpen`, `onOpenChange`, `aria-label`) with their types and JSDoc
diff --git a/openspec/changes/info-dialog-pattern/tasks.md b/openspec/changes/info-dialog-pattern/tasks.md
new file mode 100644
index 000000000..9f56235cf
--- /dev/null
+++ b/openspec/changes/info-dialog-pattern/tasks.md
@@ -0,0 +1,61 @@
+## 1. Scaffold
+
+- [x] 1.1 Create directory
+ `packages/nimbus/src/patterns/dialogs/info-dialog/`
+- [x] 1.2 Create `patterns/dialogs/index.ts` with
+ `export * from "./info-dialog"`
+- [x] 1.3 Add `export * from "./dialogs"` to
+ `packages/nimbus/src/patterns/index.ts`
+- [x] 1.4 Create `info-dialog/index.ts` barrel re-exporting the component
+ and types
+
+## 2. Implementation
+
+- [x] 2.1 Implement `info-dialog.types.ts` with `InfoDialogProps`:
+ `title: ReactNode`, `isOpen?: boolean`,
+ `onOpenChange?: (isOpen: boolean) => void`, `children: ReactNode`,
+ with JSDoc on every prop
+- [x] 2.2 Implement `info-dialog.tsx` composing `Dialog.Root`
+ (passing `isOpen`, `onOpenChange`, `isDismissable`),
+ `Dialog.Content`, `Dialog.Header` wrapping `Dialog.Title`,
+ `Dialog.Body`, `Dialog.CloseTrigger`; set `displayName = "InfoDialog"`
+- [x] 2.3 Confirm the close button accessible name from
+ `Dialog.CloseTrigger` is translated; only add `.i18n.ts` if a
+ pattern-owned user-facing string exists
+ (no `.i18n.ts` required — the close button inherits its localized
+ "Close dialog" label from `Dialog.CloseTrigger` directly)
+
+## 3. Testing
+
+- [x] 3.1 Write Storybook stories (`info-dialog.stories.tsx`) with play
+ functions covering: basic string title, ReactNode title with
+ composed content (e.g. badge + heading), long scrollable content,
+ close via X button, close via Escape key, close via overlay click,
+ focus trap while open and focus restoration on close
+- [x] 3.2 Write consumer implementation tests
+ (`info-dialog.docs.spec.tsx`) covering basic controlled usage
+
+## 4. Documentation
+
+- [x] 4.1 Create `info-dialog.dev.mdx` with: import statement, basic
+ usage, scrollable content example, accessibility section, and
+ "Escape hatch" section demonstrating the equivalent manual Dialog
+ composition (for consumers needing custom size, custom
+ dismissability, or custom aria-label)
+- [x] 4.2 Create `info-dialog.mdx` with frontmatter
+ `related-components: [Dialog]` and
+ `menu: [Patterns, Dialogs, Info dialog]`
+- [x] 4.3 Verify the generated PropsTable correctly reflects the flat
+ four-prop API
+
+## 5. Validation
+
+- [x] 5.1 `pnpm --filter @commercetools/nimbus typecheck` passes
+ (info-dialog files have only the pre-existing
+ `Cannot find module '@commercetools/nimbus'` pattern shared by
+ other stories and docs-specs — no new type errors)
+- [x] 5.2 `pnpm --filter @commercetools/nimbus build` succeeds
+- [x] 5.3 Storybook stories render with all play functions passing
+ (`pnpm test:storybook` — 5/5 pass)
+- [x] 5.4 Consumer implementation tests pass (`pnpm test` — 5/5 pass)
+- [x] 5.5 `openspec validate info-dialog-pattern --strict` passes
diff --git a/packages/nimbus/src/components/dialog/components/dialog.content.tsx b/packages/nimbus/src/components/dialog/components/dialog.content.tsx
index bf7386ab8..84c10bd8b 100644
--- a/packages/nimbus/src/components/dialog/components/dialog.content.tsx
+++ b/packages/nimbus/src/components/dialog/components/dialog.content.tsx
@@ -28,6 +28,7 @@ export const DialogContent = (props: DialogContentProps) => {
shouldCloseOnInteractOutside,
isOpen,
onOpenChange,
+ "aria-label": ariaLabel,
} = useDialogRootContext();
const modalProps = {
@@ -47,7 +48,9 @@ export const DialogContent = (props: DialogContentProps) => {
- {children}
+
+ {children}
+
diff --git a/packages/nimbus/src/docs/dialogs.mdx b/packages/nimbus/src/docs/dialogs.mdx
new file mode 100644
index 000000000..fbd659c84
--- /dev/null
+++ b/packages/nimbus/src/docs/dialogs.mdx
@@ -0,0 +1,31 @@
+---
+id: Patterns-Dialogs
+title: Dialogs
+description: Pre-composed dialog patterns built on top of the Dialog primitive for common read-only and confirmation scenarios
+order: 2
+menu:
+ - Patterns
+ - Dialogs
+tags:
+ - patterns
+ - dialogs
+ - overlays
+icon: OpenInNew
+---
+
+# Dialogs
+
+Dialog pattern components wrap the `Dialog` primitive in small, opinionated APIs for the most common dialog shapes. Use them to avoid hand-composing `Dialog.Root`, `Dialog.Content`, `Dialog.Header`, `Dialog.Title`, `Dialog.Body`, and `Dialog.CloseTrigger` for everyday cases.
+
+## When to use dialog patterns
+
+Dialog patterns are ideal for:
+- Read-only informational dialogs (help text, details, metadata)
+- Simple, scoped dialog shapes that appear many times across an app
+- Reducing boilerplate and ensuring consistent close affordances
+
+For dialogs with bespoke footer actions, non-default sizing, or custom dismiss behaviour, compose the `Dialog` primitive directly.
+
+## Available dialog patterns
+
+
diff --git a/packages/nimbus/src/patterns/dialogs/index.ts b/packages/nimbus/src/patterns/dialogs/index.ts
new file mode 100644
index 000000000..299bcdecf
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/index.ts
@@ -0,0 +1 @@
+export * from "./info-dialog";
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts b/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts
new file mode 100644
index 000000000..b83df8d65
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/index.ts
@@ -0,0 +1,2 @@
+export * from "./info-dialog";
+export * from "./info-dialog.types";
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx
new file mode 100644
index 000000000..5e1100707
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.dev.mdx
@@ -0,0 +1,229 @@
+---
+title: InfoDialog Pattern
+tab-title: Implementation
+tab-order: 3
+---
+
+## Comparison: InfoDialog vs manual Dialog composition
+
+**With InfoDialog:**
+```tsx
+
+ Some goods cannot be shipped to the selected region.
+
+```
+
+**Manual composition:**
+```tsx
+
+
+
+ Shipping restrictions
+
+
+
+ Some goods cannot be shipped to the selected region.
+
+
+
+```
+
+### When to use which
+
+**Use InfoDialog when:**
+- You need a read-only informational dialog with no footer actions
+- The default dialog size and dismiss behaviour are appropriate
+- You want a flat four-prop API instead of composing six nested elements
+
+**Use Dialog directly when:**
+- You need a non-default size
+- You need to override dismissability (disable overlay click, disable Escape)
+- Your dialog has footer actions (Cancel/Confirm, destructive action, etc.)
+
+## Getting started
+
+### Import
+
+```tsx
+import { InfoDialog, type InfoDialogProps } from '@commercetools/nimbus';
+```
+
+### Basic usage
+
+The simplest implementation with a string title and controlled open state:
+
+```jsx live-dev
+const App = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ Some goods cannot be shipped to the selected region. Review the
+ restricted items and update the shipping address if needed.
+
+
+ >
+ );
+}
+```
+
+## Usage examples
+
+### ReactNode title
+
+The `title` prop accepts any ReactNode, so you can compose a heading with inline elements like a badge or icon:
+
+```jsx live-dev
+const App = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+ Plan details
+ Pro
+
+ }
+ isOpen={isOpen}
+ onOpenChange={setIsOpen}
+ >
+
+ Your current plan includes unlimited projects and priority support.
+
+
+ >
+ );
+}
+```
+
+When the `title` is not a plain string, pass an explicit `aria-label` if the composed markup does not form a meaningful accessible name on its own:
+
+```tsx
+
+ Plan details
+ Pro
+
+ }
+ aria-label="Plan details"
+ isOpen={isOpen}
+ onOpenChange={setIsOpen}
+>
+ {/* ... */}
+
+```
+
+### Long, scrollable content
+
+Content that exceeds the available dialog height scrolls within the body; the header with the title and close button stays pinned at the top.
+
+```jsx live-dev
+const App = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ {Array.from({ length: 20 }, (_, i) => (
+
+ Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur
+ adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
+ ))}
+
+
+ >
+ );
+}
+```
+
+## Close affordances
+
+Because InfoDialog has no footer actions, the underlying Dialog is always rendered with `isDismissable` enabled so the user has multiple ways to close it:
+
+- The X button in the header
+- The Escape key
+- A click on the overlay outside the dialog content
+
+All three routes invoke `onOpenChange(false)`.
+
+## Accessibility
+
+InfoDialog inherits its accessibility guarantees from the Nimbus `Dialog` primitive:
+
+- `role="dialog"` is exposed to assistive technology while open
+- Focus moves into the dialog on open and is trapped within it
+- Focus is restored to the triggering element on close
+- When `title` is a string, the dialog's accessible name is derived from it automatically — no extra `aria-label` is required
+
+If the `title` is a composed ReactNode, pass an explicit `aria-label` to InfoDialog so the dialog has a meaningful accessible name.
+
+## Escape hatch
+
+If you need a non-default size or a custom dismiss behaviour, drop down to composing `Dialog` directly. The equivalent manual composition is:
+
+```jsx live-dev
+const App = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+
+ Shipping restrictions
+
+
+
+
+ Some goods cannot be shipped to the selected region.
+
+
+
+
+ >
+ );
+}
+```
+
+## API reference
+
+
+
+## Testing your implementation
+
+These examples demonstrate how to test your implementation when using InfoDialog within your application. The component's internal behaviour is already covered by Nimbus — these tests help you verify your integration.
+
+{{docs-tests: info-dialog.docs.spec.tsx}}
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx
new file mode 100644
index 000000000..b8b03aad5
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.docs.spec.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { userEvent } from "@testing-library/user-event";
+import { useState } from "react";
+import { InfoDialog, NimbusProvider } from "@commercetools/nimbus";
+
+/**
+ * @docs-section basic-rendering
+ * @docs-title Basic Rendering
+ * @docs-description Verify the InfoDialog opens and renders its title and content.
+ * @docs-order 1
+ */
+describe("InfoDialog - Basic rendering", () => {
+ it("renders title and children when isOpen is true", () => {
+ render(
+
+
+
Something you should know.
+
+
+ );
+
+ expect(
+ screen.getByRole("heading", { name: "Heads up" })
+ ).toBeInTheDocument();
+ expect(screen.getByText("Something you should know.")).toBeInTheDocument();
+ });
+
+ it("uses the string title as the dialog's accessible name", () => {
+ render(
+
+
+
Body
+
+
+ );
+
+ expect(screen.getByRole("dialog")).toHaveAccessibleName("Account details");
+ });
+
+ it("does not render the dialog when isOpen is false", () => {
+ render(
+
+
+
Not visible
+
+
+ );
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
+
+/**
+ * @docs-section controlled-state
+ * @docs-title Controlled Open State
+ * @docs-description Drive the InfoDialog from consumer state via isOpen and onOpenChange.
+ * @docs-order 2
+ */
+describe("InfoDialog - Controlled state", () => {
+ it("invokes onOpenChange(false) when the close button is clicked", async () => {
+ const user = userEvent.setup();
+
+ const ControlledInfoDialog = () => {
+ const [isOpen, setIsOpen] = useState(true);
+ return (
+
+
Dismissable content
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
+
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx
new file mode 100644
index 000000000..87c41c915
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.mdx
@@ -0,0 +1,42 @@
+---
+id: Patterns-InfoDialog
+title: Info dialog
+exportName: InfoDialog
+description: A pre-composed read-only informational dialog with a flat, minimal API.
+order: 999
+menu:
+ - Patterns
+ - Dialogs
+ - Info dialog
+tags:
+ - component
+ - pattern
+ - dialog
+ - InfoDialog
+related-components:
+ - Dialog
+---
+
+## Overview
+
+InfoDialog is a pattern component that wraps the Dialog primitive in a flat, minimal API for the most common read-only informational dialog shape: a title, some content, and a close affordance. It is the recommended replacement for the Merchant Center Application Kit's `InfoDialog`.
+
+The pattern exposes a small, flat set of props — `title`, `children`, and optional `isOpen` / `defaultOpen` / `onOpenChange` / `aria-label` — and delegates everything else (sizing, stacking, portaling, focus management) to the underlying `Dialog` primitive.
+
+### When to use
+
+- Displaying read-only information the user needs to acknowledge and dismiss (help text, details panels, metadata)
+- Any scenario where the dialog has no footer actions — the user's only task is to read and close
+
+### When not to use
+
+- Dialogs that need confirmation, cancellation, or destructive action buttons — use `Dialog` directly or a confirmation pattern
+- Dialogs hosting editable forms — use `Dialog` directly with form wiring
+- Any scenario that needs a non-default size or non-default dismiss behaviour — drop down to `Dialog` directly (see the escape hatch in the developer documentation)
+
+### Resources
+
+For detailed guidance on the underlying primitive, consult the component guidelines:
+
+- [Dialog Guidelines](/components/feedback/dialog) — Variants, dismissability, sizing, placement, accessibility
+- [Storybook](https://nimbus-storybook.vercel.app/?path=/docs/patterns-dialogs-infodialog--docs)
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx
new file mode 100644
index 000000000..41fc7ba10
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.stories.tsx
@@ -0,0 +1,286 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { userEvent, within, expect, waitFor } from "storybook/test";
+import { useState } from "react";
+import { Badge, Button, Flex, Stack, Text } from "@commercetools/nimbus";
+import { InfoDialog } from "./info-dialog";
+
+const meta: Meta = {
+ title: "patterns/dialogs/InfoDialog",
+ component: InfoDialog,
+};
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * Basic controlled usage with a string title. The pattern has no trigger
+ * slot — consumers render their own button and drive `isOpen` / `onOpenChange`.
+ *
+ * This story also exercises every close affordance (X button, Escape key,
+ * overlay click) and verifies focus moves into the dialog on open and is
+ * restored to the trigger on close.
+ */
+export const Base: Story = {
+ render: () => {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ <>
+
+
+
+ Some goods cannot be shipped to the selected region. Review the
+ restricted items and update the shipping address if needed.
+
+
+ >
+ );
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(
+ (canvasElement.parentNode as HTMLElement) ?? canvasElement
+ );
+ const getTrigger = () =>
+ canvas.getByRole("button", { name: "Open info dialog" });
+
+ await step("Dialog is not rendered initially", async () => {
+ expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ await step(
+ "Opens on trigger click and exposes the title as accessible name",
+ async () => {
+ await userEvent.click(getTrigger());
+
+ await waitFor(() => {
+ expect(canvas.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ const dialog = canvas.getByRole("dialog");
+ expect(dialog).toHaveAccessibleName("Shipping restrictions");
+ expect(
+ canvas.getByRole("heading", { name: "Shipping restrictions" })
+ ).toBeInTheDocument();
+ }
+ );
+
+ await step("Moves focus into the dialog on open", async () => {
+ const dialog = canvas.getByRole("dialog");
+ await waitFor(() => {
+ const active = document.activeElement;
+ expect(active === dialog || dialog.contains(active)).toBeTruthy();
+ });
+ });
+
+ await step("Closes via the X button and restores focus", async () => {
+ await userEvent.click(canvas.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ await waitFor(
+ () => {
+ expect(getTrigger()).toHaveFocus();
+ },
+ { timeout: 1000 }
+ );
+ });
+
+ await step("Closes via the Escape key", async () => {
+ await userEvent.click(getTrigger());
+
+ await waitFor(() => {
+ expect(canvas.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ await userEvent.keyboard("{Escape}");
+
+ await waitFor(() => {
+ expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+
+ await step("Closes when the overlay is clicked", async () => {
+ await userEvent.click(getTrigger());
+
+ const dialog = await waitFor(() => canvas.getByRole("dialog"));
+
+ // React Aria's useInteractOutside listens on the document and fires
+ // onOpenChange when a pointerdown lands outside the Dialog element.
+ // The overlay/backdrop is an ancestor of the dialog but outside the
+ // dialog's own subtree — walk up until we find a wrapper whose direct
+ // parent is and click its corner (outside the centered
+ // modal content).
+ let overlay: HTMLElement | null = dialog;
+ while (overlay && overlay.parentElement !== document.body) {
+ overlay = overlay.parentElement;
+ }
+ expect(overlay).not.toBeNull();
+
+ await userEvent.pointer([
+ {
+ target: overlay!,
+ coords: { clientX: 2, clientY: 2 },
+ keys: "[MouseLeft]",
+ },
+ ]);
+
+ await waitFor(() => {
+ expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+ });
+ },
+};
+
+/**
+ * Title accepts any ReactNode, so consumers can compose a heading with inline
+ * elements like a badge or icon. When the composed title would produce a
+ * confusing accessible name, pass an explicit `aria-label` to override it.
+ */
+export const WithReactNodeTitle: Story = {
+ render: () => {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ <>
+
+
+ Plan details
+ Pro
+
+ }
+ aria-label="Plan details"
+ isOpen={isOpen}
+ onOpenChange={setIsOpen}
+ >
+
+ Your current plan includes unlimited projects and priority support.
+
+
+ >
+ );
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(
+ (canvasElement.parentNode as HTMLElement) ?? canvasElement
+ );
+ const getTrigger = () =>
+ canvas.getByRole("button", { name: "Open info dialog" });
+
+ await step("Opens with a composed ReactNode title", async () => {
+ await userEvent.click(getTrigger());
+
+ await waitFor(() => {
+ expect(canvas.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ expect(canvas.getByText("Plan details")).toBeInTheDocument();
+ expect(canvas.getByText("Pro")).toBeInTheDocument();
+ });
+
+ await step(
+ "aria-label overrides the composed title as the dialog's accessible name",
+ async () => {
+ expect(canvas.getByRole("dialog")).toHaveAccessibleName("Plan details");
+ }
+ );
+
+ await step("Closes via the X button and restores focus", async () => {
+ await userEvent.click(canvas.getByRole("button", { name: /close/i }));
+
+ await waitFor(() => {
+ expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ await waitFor(
+ () => {
+ expect(getTrigger()).toHaveFocus();
+ },
+ { timeout: 1000 }
+ );
+ });
+ },
+};
+
+/**
+ * Long content scrolls within the dialog body; the header with the title and
+ * close button stays pinned at the top.
+ */
+export const LongContent: Story = {
+ render: () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const paragraphs = Array.from({ length: 25 }, (_, i) => (
+
+ Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing
+ elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
+
+ ));
+ return (
+ <>
+
+
+ {paragraphs}
+
+ >
+ );
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(
+ (canvasElement.parentNode as HTMLElement) ?? canvasElement
+ );
+
+ await step("Opens with long scrollable content", async () => {
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Open info dialog" })
+ );
+
+ await waitFor(() => {
+ expect(canvas.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ expect(
+ canvas.getByRole("heading", { name: "Terms of service" })
+ ).toBeInTheDocument();
+ expect(canvas.getByText(/^Paragraph 1:/)).toBeInTheDocument();
+ expect(
+ canvas.getByRole("button", { name: /close/i })
+ ).toBeInTheDocument();
+ });
+
+ await step(
+ "Body scrolls to reveal the last paragraph while the header stays pinned",
+ async () => {
+ const lastParagraph = canvas.getByText(/^Paragraph 25:/);
+ const header = canvas.getByRole("heading", {
+ name: "Terms of service",
+ });
+ const headerTopBeforeScroll = header.getBoundingClientRect().top;
+
+ lastParagraph.scrollIntoView({ block: "end" });
+
+ await waitFor(() => {
+ const paragraphRect = lastParagraph.getBoundingClientRect();
+ expect(paragraphRect.top).toBeLessThan(window.innerHeight);
+ expect(paragraphRect.bottom).toBeGreaterThan(0);
+ });
+
+ // Header position is unchanged because it's pinned outside the
+ // scrollable body region.
+ expect(header.getBoundingClientRect().top).toBeCloseTo(
+ headerTopBeforeScroll,
+ 0
+ );
+ }
+ );
+ },
+};
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx
new file mode 100644
index 000000000..ca463f206
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.tsx
@@ -0,0 +1,56 @@
+import { Dialog } from "@/components";
+import type { InfoDialogProps } from "./info-dialog.types";
+
+/**
+ * # InfoDialog
+ *
+ * A pre-composed, read-only informational dialog pattern built on top of the
+ * Nimbus `Dialog` primitive. Exposes a flat API (`title`, `children`, and
+ * optional `isOpen` / `defaultOpen` / `onOpenChange` / `aria-label`) for the
+ * common case of showing information the user only needs to read and dismiss.
+ *
+ * Close affordances: the X button in the header, the Escape key, and a click
+ * on the overlay all invoke `onOpenChange(false)`.
+ *
+ * @example
+ * ```tsx
+ * const [isOpen, setIsOpen] = useState(false);
+ *
+ *
+ * Some goods cannot be shipped to the selected region.
+ *
+ * ```
+ */
+export const InfoDialog = ({
+ title,
+ children,
+ isOpen,
+ defaultOpen,
+ onOpenChange,
+ "aria-label": ariaLabel,
+}: InfoDialogProps) => {
+ return (
+
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+};
+
+InfoDialog.displayName = "InfoDialog";
diff --git a/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts
new file mode 100644
index 000000000..ac85c33a2
--- /dev/null
+++ b/packages/nimbus/src/patterns/dialogs/info-dialog/info-dialog.types.ts
@@ -0,0 +1,71 @@
+import type { ReactNode } from "react";
+
+/**
+ * Props for the InfoDialog pattern component.
+ *
+ * A pre-composed read-only informational dialog built on top of the Nimbus
+ * Dialog primitive. The flat API covers the overwhelmingly common
+ * informational-dialog case (string or composed-JSX title, default size,
+ * default dismiss behaviour).
+ *
+ * Consumers needing a non-default size or custom dismissability should
+ * compose `Dialog.Root`, `Dialog.Content`, `Dialog.Header`, `Dialog.Title`,
+ * `Dialog.Body`, and `Dialog.CloseTrigger` directly (see the "Escape hatch"
+ * section in the dev documentation).
+ */
+export type InfoDialogProps = {
+ /**
+ * Title rendered inside the dialog header.
+ *
+ * When passed as a string, the dialog's accessible name is derived from
+ * the string automatically. When passed as a ReactNode (for example a
+ * composed heading with an icon or badge), consumers remain responsible
+ * for any additional a11y affordances their composition requires.
+ */
+ title: ReactNode;
+
+ /**
+ * Content rendered inside the dialog body.
+ *
+ * Long content scrolls within the body; the header remains pinned at the
+ * top of the dialog.
+ */
+ children: ReactNode;
+
+ /**
+ * Whether the dialog is open (controlled mode).
+ *
+ * Pair with `onOpenChange` to drive the dialog from consumer state. When
+ * omitted, use `defaultOpen` to start the dialog open in uncontrolled mode.
+ */
+ isOpen?: boolean;
+
+ /**
+ * Whether the dialog is open by default (uncontrolled mode).
+ *
+ * Use when the consumer does not need to observe or control the open state.
+ * Ignored when `isOpen` is provided.
+ *
+ * @default false
+ */
+ defaultOpen?: boolean;
+
+ /**
+ * Callback fired when the open state changes.
+ *
+ * Invoked with `false` when the user closes the dialog via the close
+ * button in the header, the Escape key, or a click on the overlay.
+ */
+ onOpenChange?: (isOpen: boolean) => void;
+
+ /**
+ * Accessible label for the dialog, forwarded to the underlying
+ * `Dialog.Root`.
+ *
+ * By default the dialog's accessible name is derived from `title`. Provide
+ * this override when `title` is a composed `ReactNode` whose text content
+ * would produce a confusing accessible name (for example a title that
+ * concatenates inline badges or icons).
+ */
+ "aria-label"?: string;
+};
diff --git a/packages/nimbus/src/patterns/index.ts b/packages/nimbus/src/patterns/index.ts
index f87a5f89e..256b212ac 100644
--- a/packages/nimbus/src/patterns/index.ts
+++ b/packages/nimbus/src/patterns/index.ts
@@ -1 +1,2 @@
+export * from "./dialogs";
export * from "./fields";