---
title: "Interactive Component Playground"
description: "The Fumadocs story package provides an interactive component playground system that bridges static documentation with live, editable React component previews. By combining static TypeScript prop ex..."
last_updated: "2026-10-12T07:53:59.736226+00:00"
canonical_url: "https://www.doc0.dev/docs/e8872a72-1909-479c-b585-dc74c6e26726/technical/developer-tooling/interactive-component-playground"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [packages/story/src/client/with-control.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx)
- [packages/story/src/client/arg-form.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx)
- [packages/story/src/client/compiled.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx)
- [packages/api-docs/src/components/schema/client.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/api-docs/src/components/schema/client.tsx)
- [packages/story/src/utils/generate.ts](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts)
- [packages/asyncapi/src/ui/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/asyncapi/src/ui/index.tsx)
- [packages/base-ui/src/components/type-table.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/base-ui/src/components/type-table.tsx)
- [packages/openapi/src/ui/base.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/openapi/src/ui/base.tsx)
- [packages/openapi/src/ui/operation/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/openapi/src/ui/operation/index.tsx)
- [packages/radix-ui/src/components/type-table.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/radix-ui/src/components/type-table.tsx)
- [packages/story/src/vite/index.ts](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/vite/index.ts)
- [packages/api-docs/src/components/schema/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/api-docs/src/components/schema/index.tsx)
- [packages/asyncapi/src/ui/components/server-select.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/asyncapi/src/ui/components/server-select.tsx)
- [packages/story/src/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx)
- [packages/story/src/webpack/story.ts](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/webpack/story.ts)
- [packages/radix-ui/src/layouts/flux/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/radix-ui/src/layouts/flux/index.tsx)
- [packages/base-ui/src/layouts/flux/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/base-ui/src/layouts/flux/index.tsx)
- [packages/asyncapi/src/ui/bindings/shared.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/asyncapi/src/ui/bindings/shared.tsx)
- [packages/openapi/src/ui/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/openapi/src/ui/index.tsx)
- [packages/story/src/utils/transform.ts](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/transform.ts)
- [packages/python/src/components/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/python/src/components/index.tsx)
- [packages/story/package.json](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/package.json)
- [packages/sanity/registry/accordion.component.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/sanity/registry/accordion.component.tsx)
- [packages/base-ui/src/layouts/flux/page/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/base-ui/src/layouts/flux/page/index.tsx)
- [packages/radix-ui/src/layouts/flux/page/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/radix-ui/src/layouts/flux/page/index.tsx)
- [packages/radix-ui/src/layouts/notebook/page/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/radix-ui/src/layouts/notebook/page/index.tsx)
- [packages/base-ui/src/layouts/notebook/page/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/base-ui/src/layouts/notebook/page/index.tsx)
- [packages/openapi/src/scalar/client.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/openapi/src/scalar/client.tsx)
- [packages/base-ui/src/components/dynamic-codeblock.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/base-ui/src/components/dynamic-codeblock.tsx)
- [packages/core/src/mdx-plugins/codeblock-utils.ts](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/core/src/mdx-plugins/codeblock-utils.ts)
</details>

## Overview

The Fumadocs story package provides an interactive component playground system that bridges static documentation with live, editable React component previews. By combining static TypeScript prop extraction with runtime state management, it allows documentation authors to define component stories complete with editable controls, default presets, and type validations.

Sources: [packages/story/src/client/with-control.tsx:14-21](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L14-L21), [packages/story/src/client/compiled.tsx:7-25](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx#L7-L25)

## Story Factory Definition and Architecture

### Public API Entry Points and Module Exports

The package [@fumadocs/story](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/package.json) exposes entry points through its exports map in `package.json`, supporting root integration, localization, Next.js bindings, type tree utilities, Vite plugins, and Webpack loaders.

| Export Path | Target File | Purpose |
| --- | --- | --- |
| `.` | `./dist/index.js` | Root story factory definitions and types |
| `./i18n` | `./dist/i18n.js` | Localization helpers for story UI |
| `./next` | `./dist/next/index.js` | Next.js integration entry point |
| `./next/client` | `./dist/next/client.js` | Next.js client-side runtime |
| `./type-tree` | `./dist/type-tree/index.js` | AST type tree builder utilities |
| `./vite` | `./dist/vite/index.js` | Vite plugin integration |
| `./vite/client` | `./dist/vite/client.js` | Vite client-side runtime |
| `./webpack/story` | `./dist/webpack/story.js` | Webpack loader integration |
| `./css/*` | `./css/*` | Stylesheet assets |

Sources: [packages/story/package.json:14-25](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/package.json#L14-L25)

### Factory Interfaces and Configuration Options

The core runtime contract is established in [packages/story/src/index.tsx](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx) via `defineStoryFactory`, `StoryFactoryOptions`, and `StoryOptions`.

```tsx
export interface StoryOptions<C extends FC<any>> {
  /**
   * the export name of story, necessary for TypeScript Compiler.
   *
   * @defaultValue `story`
   */
  name?: string;
  displayName?: string;
  Component: C;

  /**
   * story arguments, you can pass an array of options for multiple presets.
   */
  args?: ArgsOptions<C> | (ArgsOptions<C> & VariantInfo)[];
}

export interface ArgsOptions<C extends FC<any> = FC<any>> {
  /**
   * the default values of arguments.
   */
  initial?: ComponentPropsWithoutRef<C> | (() => Awaitable<ComponentPropsWithoutRef<C>>);
  /**
   * fixed values for arguments, will disable the relevant controls.
   */
  fixed?:
    | Partial<ComponentPropsWithoutRef<C>>
    | (() => Awaitable<Partial<ComponentPropsWithoutRef<C>>>);
  /**
   * customize the generated controls, by default generated from component props using TypeScript compiler.
   */
  controls?:
    | {
        node: TypeNode;
      }
    | {
        /** modify generated node */
        transform?: (node: TypeNode) => TypeNode;
      };
}

export { type Cache } from './cache';
export { createFileSystemCache } from './cache/fs';

export interface Story<C extends FC<any> = FC<any>> {
  /** render as a server component (require RSC). */
  WithControl: FC;
  _private_: {
    component?: C;
  };
}

export interface StoryFactoryOptions {
  cache?: Cache | false;

  tsc?: {
    /** default to `tsconfig.json` under cwd */
    tsconfigPath?: string;
  };
}

export interface StoryFactory {
  defineStory: <C extends FC<any>>(urlOrPath: URL | string, options: StoryOptions<C>) => Story<C>;
}
```
Sources: [packages/story/src/index.tsx:14-76](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L14-L76)

> [!NOTE]
> Creating stories under a Server Component (RSC) environment without build-time plugins relies entirely on runtime AST generation and requires caching to function correctly in serverless deployments. Authors are strongly encouraged to use build-time [bundler plugins](https://www.doc0.dev/docs/e8872a72-1909-479c-b585-dc74c6e26726/technical/mdx-pipeline/bundler-plugins) instead of un-cached server-side generation.

Sources: [packages/story/src/index.tsx:78-82](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L78-L82)

### Runtime Contract and Story Execution Flow

The `defineStoryFactory` function initializes a TypeScript control project and constructs a `Story` object containing the `WithControl` server component and private component references.

```tsx
export function defineStoryFactory(factoryOptions: StoryFactoryOptions = {}): StoryFactory {
  let _project: ReturnType<typeof createControlsProject> | undefined;
  const { cache = false } = factoryOptions;
  const propsDeepmerge = deepmerge({
    mergeArray: () => (_target, source) => source,
  });

  function initProject() {
    const { tsconfigPath = './tsconfig.json' } = factoryOptions.tsc ?? {};
    return (_project ??= createControlsProject(tsconfigPath));
  }

  async function generateControlsCached(filePath: string, name: string): Promise<TypeNode> {
    const fileContent = await fs.readFile(filePath, 'utf-8');

    return cached(cache, getHash(`controls:${filePath}:${name}:${fileContent}`), async () => {
      const project = await initProject();
      return generateControls('@fumadocs/story/next/client', project, filePath, name, fileContent);
    });
  }

  return {
    defineStory(urlOrPath, { Component, name = 'story', displayName, args = {} }) {
      const filePath =
        urlOrPath instanceof URL || urlOrPath.startsWith('file:///')
          ? fileURLToPath(urlOrPath)
          : urlOrPath;

      async function getProps(): Promise<WithControlProps> {
        const normalized = Array.isArray(args) ? args : [{ ...args, variant: 'default' }];

        return {
          Component,
          displayName,
          presets: await Promise.all(
            normalized.map(async (preset): Promise<WithControlProps['presets'][number]> => {
              const fixedValues =
                typeof preset.fixed === 'function' ? await preset.fixed() : preset.fixed;
              const initial =
                typeof preset.initial === 'function' ? await preset.initial() : preset.initial;
              let controls: TypeNode;

              if (preset.controls && 'node' in preset.controls) {
                controls = preset.controls.node;
              } else {
                controls = await generateControlsCached(filePath, name);

                if (preset.controls?.transform) controls = preset.controls.transform(controls);
                if (fixedValues) controls = collapse(controls, fixedValues);
              }

              return {
                variant: preset.variant,
                description: preset.description,
                controls,
                defaultValues: (fixedValues
                  ? propsDeepmerge(initial, fixedValues)
                  : initial) as Record<string, unknown>,
              };
            }),
          ),
        };
      }

      return {
        _private_: {
          component: Component,
        },
        async WithControl() {
          const { WithControl } = await import('./client/with-control');
          return <WithControl {...await getProps()} />;
        },
      };
    },
  };
}
```
Sources: [packages/story/src/index.tsx:83-158](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L83-L158)

> [!WARNING]
> When `args` is provided as a single `ArgsOptions` object rather than an array of variants, it is automatically normalized into a single-element preset array with the variant identifier set to `'default'`.

Sources: [packages/story/src/index.tsx:111-113](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L111-L113)

## TypeScript Prop Analysis and Extraction

### Source Code AST Parsing and Project Initialization

Component prop schemas are extracted statically using `ts-morph` to analyze component definitions and generate interactive control nodes. The `createControlsProject` function initializes a TypeScript compilation project from a specified configuration file path, skipping adding files from the `tsconfig.json` automatically to optimize performance.

```typescript
export async function createControlsProject(tsconfigPath: string): Promise<Project> {
  const { Project } = await import('ts-morph');

  return new Project({
    tsConfigFilePath: tsconfigPath,
    skipAddingFilesFromTsConfig: true,
  });
}
```
Sources: [packages/story/src/utils/generate.ts:5-12](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts#L5-L12)

> [!NOTE]
> `defineStoryFactory` defaults to locating `tsconfig.json` in the current working directory under `tsc.tsconfigPath` if no custom path is supplied in factory options.

Sources: [packages/story/src/index.tsx:90-93](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L90-L93)

### Type Tree Construction and Mode Handling

The `generateControls` function accepts a compilation mode, a `Project` instance, a file path, an export name, and file content to synthesize a `TypeNode` tree. It creates a unique type alias named `_StoryProps_${exportName}_` utilizing `GetProps` from either `@fumadocs/story/vite/client` or `@fumadocs/story/next/client`.

```typescript
export function generateControls(
  mode: Mode,
  project: Project,
  filePath: string,
  exportName: string,
  content: string,
): TypeNode {
  const aliasName = `_StoryProps_${exportName}_`;
  const sourceFile =
    project.getSourceFile(filePath) ??
    project.createSourceFile(filePath, content, {
      overwrite: true,
    });

  if (!sourceFile.getTypeAlias(aliasName)) {
    sourceFile.addTypeAlias({
      isExported: true,
      name: aliasName,
      type: `import('${mode}').GetProps<typeof ${exportName}>`,
    });
  }

  const declarations = sourceFile.getExportedDeclarations();
  const declaration = declarations.get(aliasName)?.[0];
  if (!declaration || !declarations.get(exportName)) {
    throw new Error(`Export "${exportName}" not found in file "${filePath}"`);
  }

  return createTypeTreeBuilder(project, [literalEnumHandler]).typeToNode(
    declaration.getType(),
    declaration,
  );
```
Sources: [packages/story/src/utils/generate.ts:16-47](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts#L16-L47)

| Mode Parameter | Client Import Target | Purpose |
| --- | --- | --- |
| `@fumadocs/story/vite/client` | `@fumadocs/story/vite/client` | Vite environment prop extraction |
| `@fumadocs/story/next/client` | `@fumadocs/story/next/client` | Next.js environment prop extraction |

Sources: [packages/story/src/utils/generate.ts:14-14](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts#L14-L14), [packages/story/src/utils/generate.ts:34-34](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts#L34-L34), [packages/story/src/index.tsx:100-100](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L100-L100)

> [!CAUTION]
> If the requested export name or synthesized type alias cannot be located within the parsed source file, `generateControls` immediately throws an error indicating that the export was not found.

Sources: [packages/story/src/utils/generate.ts:40-42](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/generate.ts#L40-L42)

### Schema Collapsing and Caching

Once the raw `TypeNode` tree is generated, fixed argument values can be applied to collapse the control schema. The `collapse` utility modifies the control definition directly using provided static values.

```tsx
if (fixedValues) controls = collapse(controls, fixedValues);
```
Sources: [packages/story/src/index.tsx:131-131](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L131-L131)

To avoid repetitive AST parsing overhead during runtime server-side generation, `generateControlsCached` computes a SHA-256 hash of the file contents, path, and export name, checking the provided cache layer before executing project analysis.

```tsx
async function generateControlsCached(filePath: string, name: string): Promise<TypeNode> {
  const fileContent = await fs.readFile(filePath, 'utf-8');

  return cached(cache, getHash(`controls:${filePath}:${name}:${fileContent}`), async () => {
    const project = await initProject();
    return generateControls('@fumadocs/story/next/client', project, filePath, name, fileContent);
  });
}
```
Sources: [packages/story/src/index.tsx:95-102](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L95-L102)

> [!TIP]
> Caching hashes are truncated to 32 hexadecimal characters generated via Node's `crypto` module to ensure unique cache keys for each file content state.

Sources: [packages/story/src/index.tsx:160-162](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/index.tsx#L160-L162)

## Bundler Transformations and Compilation Pipeline

### Vite Plugin Integration

The Vite plugin integrates story compilation into the build pipeline by intercepting modules matching file filters. It exports a default function accepting `StoryPluginOptions` with `tsconfigPath` and a `filter` regular expression defaulting to `/\.story\.(js|jsx|ts|tsx)$/`. 

```typescript
export interface StoryPluginOptions {
  /**
   * Path to `tsconfig.json`.
   *
   * @default "tsconfig.json"
   */
  tsconfigPath?: string;

  /**
   * Filter story files to transform by id (regex)
   *
   * @default `/\.story\.(js|jsx|ts|tsx)$/`
   */
  filter?: RegExp;
}
```
Sources: [packages/story/src/vite/index.ts:7-21](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/vite/index.ts#L7-L21)

The plugin configures its name as `fumadocs:story` with `enforce: 'pre'`. During `configResolved`, it resolves the `tsconfig.json` path relative to the root directory if an explicit path is omitted.

```typescript
return {
  name: 'fumadocs:story',
  enforce: 'pre',
  configResolved(resolved) {
    tsconfigPath = pluginOptions.tsconfigPath ?? path.join(resolved.root, 'tsconfig.json');
  },
```
Sources: [packages/story/src/vite/index.ts:28-33](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/vite/index.ts#L28-L33)

> [!NOTE]
> The Vite plugin lazily initializes its `ts-morph` project promise via `createControlsProject(tsconfigPath)` on the first filtered module transformation handler execution and targets `@fumadocs/story/vite/client`.

Sources: [packages/story/src/vite/index.ts:34-41](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/vite/index.ts#L34-L41)

### Webpack Loader Integration

The Webpack loader reads `StoryLoaderOptions` containing an optional `tsconfigPath`. It sets `this.cacheable(true)` and retrieves asynchronous execution controls via `this.async()`.

```typescript
export interface StoryLoaderOptions {
  /**
   * Path to `tsconfig.json`.
   *
   * @default "tsconfig.json"
   */
  tsconfigPath?: string;
}

let projectPromise: Promise<Project> | undefined;

const loader: LoaderDefinitionFunction<StoryLoaderOptions> = function (source) {
  const callback = this.async();
  const options = this.getOptions();
  this.cacheable(true);

```
Sources: [packages/story/src/webpack/story.ts:7-22](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/webpack/story.ts#L7-L22)

The loader resolves the configuration file path against `this.rootContext` when `options.tsconfigPath` is undefined. It invokes `transformStoryFile` specifying `@fumadocs/story/next/client`, passing the loaded source string, resource path, and the initialized `ts-morph` project.

```typescript
const run = async () => {
  if (!projectPromise) {
    const resolvedTsconfig = options.tsconfigPath ?? path.join(this.rootContext, 'tsconfig.json');
    projectPromise = createControlsProject(resolvedTsconfig);
  }

  try {
    const transformed = transformStoryFile(
      '@fumadocs/story/next/client',
      source,
      this.resourcePath,
      await projectPromise!,
    );
```
Sources: [packages/story/src/webpack/story.ts:23-35](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/webpack/story.ts#L23-L35)

| Loader Option | Type | Default Value | Purpose |
| --- | --- | --- | --- |
| `tsconfigPath` | `string` | `undefined` | Custom file system path pointing to `tsconfig.json` |

Sources: [packages/story/src/webpack/story.ts:7-14](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/webpack/story.ts#L7-L14)

> [!WARNING]
> Non-Error exceptions caught during loader execution are rethrown directly, whereas standard `Error` instances are passed to the Webpack callback function.

Sources: [packages/story/src/webpack/story.ts:37-41](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/webpack/story.ts#L37-L41)

### AST Code Transformation Engine

The shared `transformStoryFile` utility processes source files by creating an in-memory `ts-morph` source file representation with overwriting enabled. It scans the source file for story definitions using `findDefineStoryCalls`.

```typescript
export function transformStoryFile(
  mode: Mode,
  code: string,
  id: string,
  project: Project,
): string | undefined {
  const sourceFile = project.createSourceFile(id, code, { overwrite: true });
  const calls = findDefineStoryCalls(sourceFile);
  if (calls.length === 0) return;
```
Sources: [packages/story/src/utils/transform.ts:25-33](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/transform.ts#L33)

If no story calls exist, the transformer returns `undefined`. For each valid call with exactly one argument, it generates control nodes and injects them into the options object literal under the `_generated` property.

```typescript

for (const parsed of calls) {
  const args = parsed.call.getArguments();
  // invalid structure
  if (args.length !== 1) continue;

  const controls = generateControls(mode, project, id, parsed.exportName, code);
  injectControls(parsed, controls);
}

return sourceFile.getFullText();
```
Sources: [packages/story/src/utils/transform.ts:34-44](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/transform.ts#L34-L44)

> [!CAUTION]
> `injectControls` throws an error if the argument passed to `defineStory()` is not an object literal expression, preventing non-object configurations from receiving generated control metadata.

Sources: [packages/story/src/utils/transform.ts:8-15](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/utils/transform.ts#L8-L15)

## Interactive Playground State and Wrappers

### Story Definition Factory and Preset Normalization

The `defineStoryFactory` function normalizes component story configurations into structured control presets using `@fastify/deepmerge` with a custom array merge strategy that prefers source arrays over targets.

```tsx
export function defineStoryFactory(): StoryFactory {
  const propsDeepmerge = deepmerge({
    mergeArray: () => (_target, source) => source,
  });

```
Sources: [packages/story/src/client/compiled.tsx:70-74](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx#L70-L74)

When `args` is passed as a single configuration object instead of an array, it is wrapped into a single-element array with a default variant name. Each preset evaluates `initial` and `fixed` arguments as static values or functions, and assigns control type nodes from explicit option overrides or by deserializing build-time generated metadata.

```tsx
function getProps(): WithControlProps {
  const normalized = Array.isArray(args) ? args : [{ ...args, variant: 'default' }];

  return {
    Component,
    displayName,
    presets: normalized.map((preset) => {
      const fixedValues = typeof preset.fixed === 'function' ? preset.fixed() : preset.fixed;
      const initial =
        typeof preset.initial === 'function' ? preset.initial() : preset.initial;
      let controls: TypeNode;

      if (preset.controls && 'node' in preset.controls) {
        controls = preset.controls.node;
      } else if (_generated) {
        generatedControls ??= deserialize(_generated.controls) as TypeNode;
        controls = generatedControls;
      } else {
        throw new Error(
          `[@fumadocs/story] No generated data for story, make sure the Vite plugin or Turbopack loader is configured correctly`,
        );
      }

      return {
        variant: preset.variant,
        description: preset.description,
        controls,
        defaultValues: (fixedValues
          ? propsDeepmerge(initial, fixedValues)
          : initial) as Record<string, unknown>,
      };
    }),
  };
}
```
Sources: [packages/story/src/client/compiled.tsx:79-112](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx#L79-L112)

| Property | Type | Description |
| --- | --- | --- |
| `initial` | `ComponentPropsWithoutRef<C> \| (() => ComponentPropsWithoutRef<C>)` | Default argument values or factory function |
| `fixed` | `Partial<ComponentPropsWithoutRef<C>> \| (() => Partial<ComponentPropsWithoutRef<C>>)` | Fixed argument values that disable matching controls |
| `controls` | `{ node: TypeNode }` | Optional custom TypeScript control node override |

Sources: [packages/story/src/client/compiled.tsx:27-42](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx#L27-L42)

> [!CAUTION]
> If a story lacks build-time generated metadata and provides no explicit control node, `defineStory` throws an error requiring a configured Vite plugin or Turbopack loader.

Sources: [packages/story/src/client/compiled.tsx:96-100](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/compiled.tsx#L96-L100)

### Client Control Container and Variant Selection

The `WithControl` component manages active story variant state and wraps the interactive playground interface inside an `@fumari/stf` data engine context provider.

```tsx
export function WithControl({ presets, displayName, Component }: WithControlProps) {
  const t = useTranslations({ note: 'story controls' });
  const [variant, setVariant] = useState(presets[0].variant);
  const preset = presets.find((preset) => preset.variant === variant);
  const stf = useStf({
    defaultValues: preset?.defaultValues,
  });

  return (
    <StfProvider value={stf}>
```
Sources: [packages/story/src/client/with-control.tsx:28-37](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L28-L37)

When multiple presets exist, a dropdown select component renders all variant names and descriptions, updating the active variant state and resetting the STF data engine with the selected preset's default values upon change.

```tsx
{presets.length > 1 && (
  <Select
    value={variant}
    onValueChange={(value) => {
      const preset = presets.find((preset) => preset.variant === value);
      if (preset) {
        setVariant(value);
        stf.dataEngine.reset(preset.defaultValues ?? {});
      }
    }}
  >
    <SelectTrigger
      variant="ghost"
      className="w-fit ms-auto text-fd-muted-foreground text-xs font-medium"
    >
      <SelectValue placeholder={t('No Variant')}>{preset?.variant}</SelectValue>
    </SelectTrigger>
    <SelectContent>
      {presets.map((item) => (
        <SelectItem key={item.variant} value={item.variant}>
          <p className="text-xs font-medium">{item.variant}</p>
          <p className="text-xs text-fd-muted-foreground">{item.description}</p>
        </SelectItem>
      ))}
    </SelectContent>
  </Select>
)}
```
Sources: [packages/story/src/client/with-control.tsx:41-67](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L41-L67)

```tsx
{preset && (
  <FieldSet
    field={preset.controls}
    fieldName={[]}
    name={t('Props')}
    className="max-h-[600px] overflow-auto"
  />
)}
```
Sources: [packages/story/src/client/with-control.tsx:70-77](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L70-L77)

> [!NOTE]
> The outer container applies the `not-prose` Tailwind utility class along with border, shadow, and background variables matching Fumadocs card tokens.

Sources: [packages/story/src/client/with-control.tsx:38-38](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L38-L38)

### Story Component Rendering and Throttled Updates

The internal `StoryComponent` subscribes to state updates from the STF data engine via `useListener`, utilizing a 100-millisecond window timer to throttle argument updates and applying React's `useDeferredValue` hook to keep rendering responsive.

```tsx
function StoryComponent({ Component }: { Component: FC }) {
  const t = useTranslations({ note: 'story error boundary' });
  const engine = useDataEngine();
  const timerRef = useRef(0);
  const [args, setArgs] = useState(() => engine.getData());
  const deferredArgs = useDeferredValue(args);
  useListener({
    onUpdate() {
      if (timerRef.current) window.clearTimeout(timerRef.current);
      timerRef.current = window.setTimeout(() => setArgs({ ...engine.getData() }), 100);
    },
  });
```
Sources: [packages/story/src/client/with-control.tsx:83-94](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L83-L94)

The target component renders within a React Error Boundary and a Suspense boundary, providing an error recovery fallback view featuring an alert icon, error description string, and a reset button.

```tsx
return (
  <ErrorBoundary
    fallbackRender={({ error, resetErrorBoundary }) => (
      <div className="p-3 border rounded-lg bg-fd-card text-fd-card-foreground text-sm">
        <p className="inline-flex items-center gap-2 font-medium mb-2">
          <AlertCircle className="text-fd-error size-4" />
          {t('Encountered error when rendering the component.')}
        </p>
        <p className="text-fd-muted-foreground mb-2">{String(error)}</p>
        <button
          className={cn(buttonVariants({ variant: 'primary', size: 'sm' }))}
          onClick={() => resetErrorBoundary()}
        >
          {t('Reset')}
        </button>
      </div>
    )}
  >
    <Suspense>
      <Component {...deferredArgs} key={undefined} />
    </Suspense>
  </ErrorBoundary>
);
```
Sources: [packages/story/src/client/with-control.tsx:96-118](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L96-L118)

> [!WARNING]
> Clearing an active update timer requires checking `timerRef.current` before calling `window.clearTimeout` to prevent unhandled reference execution leaks.

Sources: [packages/story/src/client/with-control.tsx:91-91](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/with-control.tsx#L91-L91)

## Dynamic Schema Form Controls

### Recursive Object and Field Input Rendering

The story form controls architecture relies on specialized input components that map type nodes to interactive form elements via `@fumari/stf` data engine hooks. The `ObjectInput` component processes object type nodes by calling `useObject` with a default value generator derived from `getDefaultValue` and mapping property keys to their respective type signatures. Each property renders a child `FieldSet` component, passing down the field name, node definition, required status, and key identifier.

```tsx
export function ObjectInput({
  field: node,
  fieldName,
  ...props
}: {
  field: ObjectNode;
  fieldName: FieldKey;
} & ComponentProps<'div'>) {
  const { properties } = useObject(fieldName, {
    defaultValue: () => getDefaultValue(node) as object,
    properties: Object.fromEntries(node.properties.map((prop) => [prop.name, prop.type])),
  });

  return (
    <div {...props} className={cn('grid grid-cols-1 gap-4 @md:grid-cols-2', props.className)}>
      {properties.map((child) => {
        const prop = node.properties.find((p) => p.name === child.key);
        if (!prop) return null;

        return (
          <FieldSet
            key={child.key}
            name={child.key}
            field={prop.type}
            fieldName={child.field}
            isRequired={prop.required}
          />
        );
      })}
    </div>
  );
}
```
Sources: [packages/story/src/client/arg-form.tsx:54-85](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L54-L85)

For individual field bindings, `FieldInput` evaluates the type node classification to determine which interactive control to display. Primitive types such as `null`, `undefined`, `never`, `unknown`, and `literal` return `null` and render no interactive inputs. Enum types generate a selection dropdown using `Select` and `SelectItem` components, where each member index maps to its corresponding value, and non-required fields include an option to clear the value to an unset state.

```tsx
export function FieldInput({
  field,
  fieldName,
  isRequired,
  ...props
}: HTMLAttributes<HTMLElement> & {
  field: TypeNode;
  isRequired?: boolean;
  fieldName: FieldKey;
}) {
  const engine = useDataEngine();
  const t = useTranslations({ note: 'story arguments form' });
  const [value, setValue] = useFieldValue(fieldName);
  const id = stringifyFieldKey(fieldName);

  function renderUnset(children: ReactNode) {
    return (
      <div {...props} className={cn('flex flex-row gap-2', props.className)}>
        {children}
        {value !== undefined && !isRequired && (
          <button
            type="button"
            onClick={() => engine.delete(fieldName)}
            className="text-fd-muted-foreground"
          >
            <X className="size-4" />
          </button>
        )}
      </div>
    );
  }

  if (
    field.type === 'null' ||
    field.type === 'undefined' ||
    field.type === 'never' ||
    field.type === 'unknown' ||
    field.type === 'literal'
  ) {
    return null;
  }

  if (field.type === 'enum') {
    const idx = field.members.findIndex((m) => m.value === value);

    return (
      <Select
        value={idx >= 0 ? String(idx) : '-1'}
        onValueChange={(v: string) => {
          const index = Number(v);
          if (index >= 0 && index < field.members.length) {
            setValue(field.members[index]!.value);
          } else {
            setValue(undefined);
          }
        }}
      >
        <SelectTrigger id={id} {...props}>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {field.members.map((member, i) => (
            <SelectItem key={i} value={String(i)}>
              {member.label}
            </SelectItem>
          ))}
          {!isRequired && (
            <SelectItem value="-1">
              <span className="text-fd-muted-foreground">{t('Unset')}</span>
            </SelectItem>
          )}
        </SelectContent>
      </Select>
    );
  }
```
Sources: [packages/story/src/client/arg-form.tsx:112-186](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L112-L186)

Boolean fields similarly utilize selection controls mapping to stringified boolean values or an undefined unset option when fields are optional. Date fields render native date input elements wrapped in `renderUnset`, which appends a deletion button whenever a value is present on an optional field.

```tsx
if (field.type === 'boolean') {
  return (
    <Select
      value={String(value)}
      onValueChange={(val: string) => setValue(val === 'undefined' ? undefined : val === 'true')}
    >
      <SelectTrigger id={id} {...props}>
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="true">{t('True')}</SelectItem>
        <SelectItem value="false">{t('False')}</SelectItem>
        {!isRequired && (
          <SelectItem value="undefined">
            <span className="text-fd-muted-foreground">{t('Unset')}</span>
          </SelectItem>
        )}
      </SelectContent>
    </Select>
  );
}
if (field.type === 'date') {
  return renderUnset(
    <Input
      id={id}
      placeholder={t('Enter date')}
      type="date"
```
Sources: [packages/story/src/client/arg-form.tsx:188-214](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L188-L214)

> [!NOTE]
> The `renderUnset` utility checks `value !== undefined && !isRequired` before rendering the clear button equipped with a Lucide `X` icon, allowing users to remove optional property overrides.

Sources: [packages/story/src/client/arg-form.tsx:127-142](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L127-L142)

### Raw JSON Textarea Dispatchers

When complex or unmapped structures require raw manipulation, the `JsonInput` component provides a controlled textarea interface bound to the active field name via the data engine. The local state initializes with a formatted JSON string generated by parsing an initial empty object through `engine.init(fieldName, {})`.

```tsx
export function JsonInput({ fieldName }: { fieldName: FieldKey }) {
  const engine = useDataEngine();
  const [error, setError] = useState<string | null>(null);
  const [value, setValue] = useState(() => JSON.stringify(engine.init(fieldName, {}), null, 2));

```
Sources: [packages/story/src/client/arg-form.tsx:87-91](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L87-L91)

As the user modifies the textarea content, the `onChange` handler updates the local value state and attempts to parse the input as JSON, dispatching successful updates directly to the data engine via `engine.update`. If parsing fails, caught syntax errors are captured in local state and displayed within a monospace error footer.

```tsx
  <div className="flex flex-col bg-fd-secondary text-fd-secondary-foreground overflow-hidden border rounded-lg">
    <textarea
      value={value}
      className="p-2 h-[240px] text-sm font-mono resize-none focus-visible:outline-none"
      onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
        setValue(e.target.value);
        try {
          engine.update(fieldName, JSON.parse(e.target.value));
          setError(null);
        } catch (err) {
          if (err instanceof Error) setError(err.message);
        }
      }}
    />
    <p className="p-2 text-xs font-mono border-t text-red-400 empty:hidden">{error}</p>
  </div>
);
```
Sources: [packages/story/src/client/arg-form.tsx:93-109](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L93-L109)

> [!WARNING]
> Malformed JSON input throws a parsing error inside the textarea event handler; this error is caught and rendered in the error paragraph without disrupting the parent form state.

Sources: [packages/story/src/client/arg-form.tsx:97-106](https://github.com/blade47/fumadocs/blob/a9f988897e7dc4ef3d62b2865d80da2f5607701e/packages/story/src/client/arg-form.tsx#L97-L106)

## Related

- [TypeScript Type Table Extraction](https://www.doc0.dev/docs/e8872a72-1909-479c-b585-dc74c6e26726/technical/developer-tooling/typescript-type-table-extraction)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/e8872a72-1909-479c-b585-dc74c6e26726/llms.txt) for all pages in this wiki.
