diff --git a/.changeset/fuzzy-portals-rest.md b/.changeset/fuzzy-portals-rest.md
new file mode 100644
index 0000000..9502a25
--- /dev/null
+++ b/.changeset/fuzzy-portals-rest.md
@@ -0,0 +1,5 @@
+---
+"openstory": patch
+---
+
+Keep centered stories stable when components render content through portals.
diff --git a/packages/openstory/src/cli/build.ts b/packages/openstory/src/cli/build.ts
index e3bf311..d2c1c51 100644
--- a/packages/openstory/src/cli/build.ts
+++ b/packages/openstory/src/cli/build.ts
@@ -13,6 +13,7 @@ import {
DEFAULT_IGNORE_GLOBS,
DEFAULT_STORY_GLOBS,
OPENSTORY_DEFAULT_LAYOUT,
+ OPENSTORY_IFRAME_STYLES,
OPENSTORY_LAYOUT_CLASSES,
OPENSTORY_ROOT_ELEMENT_ID,
VIRTUAL_STORY_ENTRY_ID,
@@ -118,10 +119,7 @@ const renderStoryHtmlForBuild = (
${escapedStoryId} · ${titleSuffix}
${cssLinks}
diff --git a/packages/openstory/src/constants.ts b/packages/openstory/src/constants.ts
index c60b618..7304068 100644
--- a/packages/openstory/src/constants.ts
+++ b/packages/openstory/src/constants.ts
@@ -50,6 +50,10 @@ export const OPENSTORY_LAYOUT_CLASSES: Record = {
padded: "openstory-layout-padded",
};
export const OPENSTORY_DEFAULT_LAYOUT = "padded";
+export const OPENSTORY_IFRAME_STYLES = ` html, body { margin: 0; padding: 0; min-height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+ body.openstory-layout-centered #${OPENSTORY_ROOT_ELEMENT_ID} { display: grid; place-items: center; min-height: 100vh; padding: 16px; box-sizing: border-box; }
+ body.openstory-layout-fullscreen #${OPENSTORY_ROOT_ELEMENT_ID} { min-height: 100vh; }
+ body.openstory-layout-padded #${OPENSTORY_ROOT_ELEMENT_ID} { padding: 16px; }`;
export const URL_KV_PAIR_SEPARATOR = ";";
export const URL_KV_KEY_VALUE_SEPARATOR = ":";
diff --git a/packages/openstory/src/plugin/virtual-modules.ts b/packages/openstory/src/plugin/virtual-modules.ts
index 9634b5c..8de7d2f 100644
--- a/packages/openstory/src/plugin/virtual-modules.ts
+++ b/packages/openstory/src/plugin/virtual-modules.ts
@@ -1,6 +1,7 @@
import {
OPENSTORY_DEFAULT_LAYOUT,
OPENSTORY_FRAMEWORK_TO_ADAPTER,
+ OPENSTORY_IFRAME_STYLES,
OPENSTORY_LAYOUT_CLASSES,
OPENSTORY_ROOT_ELEMENT_ID,
VIRTUAL_NULL_PREFIX,
@@ -94,10 +95,7 @@ export const renderStoryIframeHtml = (options: RenderStoryIframeHtmlOptions): st
${escapedStoryId} · ${titleSuffix}
diff --git a/packages/openstory/tests/plugin/virtual-modules.test.ts b/packages/openstory/tests/plugin/virtual-modules.test.ts
index 3601ad4..361bb8e 100644
--- a/packages/openstory/tests/plugin/virtual-modules.test.ts
+++ b/packages/openstory/tests/plugin/virtual-modules.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { synthesizeStoryEntry } from "../../src/plugin/virtual-modules.js";
+import { renderStoryIframeHtml, synthesizeStoryEntry } from "../../src/plugin/virtual-modules.js";
import type { Framework, ManifestStory } from "../../src/types.js";
const baseSynthesizedStory: ManifestStory = {
@@ -38,6 +38,21 @@ const FRAMEWORK_RENDER_EXPECTATIONS: Record {
+ it("centers the story root without making body portals grid items", () => {
+ const html = renderStoryIframeHtml({
+ story: {
+ ...baseSynthesizedStory,
+ parameters: { layout: "centered" },
+ },
+ });
+
+ expect(html).toContain('body class="openstory-layout-centered"');
+ expect(html).toContain("body.openstory-layout-centered #openstory-root { display: grid;");
+ expect(html).not.toMatch(/body\.openstory-layout-centered\s*\{\s*display:\s*grid/);
+ });
+});
+
describe("synthesizeStoryEntry - synthesized component stories", () => {
for (const framework of Object.keys(FRAMEWORK_RENDER_EXPECTATIONS) as Framework[]) {
it(`emits a render function for ${framework}`, () => {