Making Connections
Integrating a robust application framework with a documentation tool often involves reconciling different philosophies on how code should be built and rendered. I recently worked on integrating an Ember UI library with Storybook 10, and I encountered a specific architectural challenge. I actually tried and failed to combine the two many times before a peer suggested a bleeding edge feature that was just released might be my path to success.
My goal was straightforward: combine the standards based rendering of Ember 6.8 with the documentation environment of Storybook. While leaf components (like buttons) were easy to render, composite components, such as Accordions or Menus that rely on nested leaf components, required a different approach.
Here is how I solved the composite component problem using a pattern I call the Transient Root.
The Architecture
To keep the system maintainable, I established a “Russian Doll” architecture that separates the build concerns from the documentation display:
ui-components: The core V2 Addon containing the source code.sample-site: A standard Ember application. It consumes the addon and acts as the compiler and bundler.storybook: A static HTML/Vite site that consumes the output of the sample site to render components.
In this setup, Storybook acts strictly as a viewer. It doesn’t compile Ember templates itself; it injects a runtime built by the sample-site.
The Challenge: renderComponent and composite components
My integration relies on the renderComponent API introduced in Ember (RFC #1099). This API allows me to mount a component instance to a DOM element, which works perfectly for simple components:
await renderComponent(MyButton, {
element: document.getElementById('storybook-root'),
args: { label: 'Click Me' }
});
However, composite components are designed to be “Headless,” yielding context to their children via blocks.
<Accordion as |item|>
<item.Trigger>Open Me</item.Trigger>
<item.Content>Content</item.Content>
</Accordion>
The limitation I faced is that renderComponent accepts a component definition and arguments, but it does not support passing composite components. When I attempted to render the Accordion directly, it rendered an empty shell because there was no mechanism to inject the required child components via the API.
The Solution: The Transient Root
Since renderComponent can only render a component instance, I realized I had to shift the responsibility of creating the composite components back to the Ember build. I call this a Transient Root Component, only real value is in this testing solution.
Instead of trying to construct the blocks inside the JavaScript story file, I create a wrapper component in the sample-site that already contains the necessary template structure.
1. Create the Wrapper
In the sample-site, I define a .gts component specifically for the demonstration.
// app/components/demos/accordion-demo.gts
import Accordion from '@ember-ui/components/accordion';
export default class AccordionDemo extends Component {
<template>
<Accordion @type={{@type}} as |a|>
<a.Item @value="item-1">
<a.Trigger>Item 1</a.Trigger>
<a.Content>
This content is standard Ember template code.
</a.Content>
</a.Item>
</Accordion>
</template>
}
2. Export to Runtime
I export this component via the storybook-entry.ts file, ensuring it is included in the pre-built runtime bundle that Storybook consumes.
3. Render the Wrapper
In the Storybook file, I instruct the adapter to render AccordionDemo rather than Accordion. I can still pass args (like @type) to the wrapper, which forwards them to the actual component.
// accordion.stories.ts
export const Default = {
render: () => createEmberStory({
component: 'AccordionDemo', // The wrapper acts as the root
args: { type: 'single' }
})
};
It Works, Next Problem
This approach avoids the need for complex workarounds like runtime string compilation or virtual block mocking. By using the Transient Root pattern, I achieve Environment Parity: the code running in Storybook is standard Glimmer template code, compiled by the same build pipeline used in production.
This ensures that my documentation accurately reflects the behavior of the library while respecting the strict API boundaries of Ember 6.8.