Skip to content

Controls & ArgTypes

Your component’s props table and description are generated from the JSDoc in its .astro frontmatter. Story files don’t need to restate any of it.

Write a JSDoc block above the Props declaration, and one above each prop:

---
/**
* A simple content card with optional highlight styling.
*/
interface Props {
/** Card heading text. */
title?: string;
/** Card body text. */
content?: string;
/** Applies a highlighted visual style. */
highlight?: boolean;
}
const { title = 'Default title', content = 'Default content', highlight = false } = Astro.props;
---

The story file only names the component:

import Card from './Card.astro';
export default {
title: 'Components/Card',
component: Card,
};

That produces the component description, a row per prop with its description and type, and the defaults read from the Astro.props destructuring.

From the componentBecomes
JSDoc above Props (or a block at the top of the frontmatter)The component description
JSDoc above a propThat prop’s description
The prop’s TypeScript typeThe Type column
A default in the Astro.props destructuringThe Default column
A union of string literalsA select control with those options
A prop that is neither optional nor defaultedMarked Required
@default on a propThe Default column, when the destructuring has none

Types imported from another file work too, including via tsconfig paths aliases — the extractor uses your project’s tsconfig.json.

Components that extend Astro’s DOM types pick up a lot:

---
import type { HTMLTag, Polymorphic } from 'astro/types';
interface Props<Tag extends HTMLTag = 'button' | 'a'> extends Polymorphic<{ as: Tag }> {
/** Disables interaction. */
disabled?: boolean;
}
const { as: Tag = 'button', href, class: className } = Astro.props as Props;
---

Polymorphic alone contributes around 200 attributes, so props declared only in a dependency are filtered out by default. Two things keep the useful ones:

  • A prop you destructure from Astro.props is always kept, wherever its type came from. That’s what puts href and class in the table above.
  • A prop you redeclare in your own Props is always kept, even when the same name also exists on the DOM type.

To keep more, pass your own filter:

.storybook/main.js
export default {
framework: {
name: '@storybook-astro/framework',
options: {
docgen: {
// Keep everything, including inherited DOM attributes.
propFilter: () => true,
},
},
},
};

argTypes still works, and wins over anything extracted. Use it to change how a prop is presented rather than to restate it:

export default {
title: 'Components/Card',
component: Card,
argTypes: {
// Force a colour picker for a prop typed as a plain string.
accent: { control: 'color' },
// Hide an inherited prop that isn't interesting for this story.
class: { table: { disable: true } },
},
};

If you find yourself writing description, table.type or table.defaultValue by hand, that belongs in the component’s JSDoc instead — otherwise the two drift apart and the story file quietly wins.

Slots have no TypeScript representation, so they never appear in the props table. Describe them in the component’s JSDoc block, and pass them through args.slots:

export const WithMain = {
args: {
title: 'Hello',
slots: { main: '<p>Slot content</p>' },
},
};

By default extraction runs in the Vite plugin, on the dev server. Storybook 10.6 can instead run it in a long-lived worker thread it owns, which keeps the TypeScript work off the critical path:

.storybook/main.js
export default {
features: {
experimentalDocgenServer: true,
},
};

The props table and description are the same either way — it is the same extractor, just running somewhere else. What you gain is that a large component library no longer blocks story transforms while its types are checked, and static builds get per-component JSON snapshots under services/core/docgen/ instead of inlining docgen into the preview bundle.

Two caveats:

  • The feature is experimental upstream, and Storybook notes the payload shape may still change. It is off unless you turn it on.
  • docgen.propFilter is ignored here. A filter is a function, and the worker receives its configuration as plain data across a thread boundary, so there is no way to send one. Leave the feature off if you need a custom filter — you’ll get a warning if both are set.

On Storybook 10.0–10.5 the flag is accepted but the helpers this builds on don’t exist yet, so extraction stays in the builder and logs a note saying so.

.storybook/main.js
options: {
docgen: false,
}

Extraction is also skipped automatically when @storybook/addon-docs isn’t installed, and during test builds.

It needs TypeScript 5.0 or newer, which Astro projects already have. Without it, docgen is skipped with a warning and everything else keeps working.

Docs pages render a Show code block under each story, and stories with parameters.docs.codePanel = true also get a Code panel. Both show the Astro template your story’s args describe, generated fresh on every render — so it follows along as you change Controls:

---
import Card from './Card.astro';
---
<Card title="Hello" featured>
<p>Body content</p>
</Card>

The snippet describes the component and its args, not the decorators around it, so it stays the usage you would copy into a page.

Arg valueEmitted as
'text'title="text" — single-quoted if it contains ", a template literal if it contains both quote kinds or a newline
truefeatured (bare attribute)
falsefeatured={false}
number / bigintcount={42}
Datepublished={new Date("2026-06-11T10:00:00.000Z")}
object / arrayauthor={author}, with const author = … hoisted into the frontmatter
'', null, undefined, functionsomitted

args.slots becomes the component’s children — the default slot inline, and named slots wrapped in <Fragment slot="name">. A component (rather than a string) passed as slot content is shown as a placeholder comment, since it has no literal template form.

The import path is always written as a sibling (./Card.astro). It is a usage sample rather than a copy of your file layout, so adjust it to wherever the component actually lives.

docs.codePanel is an ordinary Storybook parameter, so setting it once in .storybook/preview.js enables the Code panel for every story in the project — no per-story opt-in:

.storybook/preview.js
const preview = {
parameters: {
docs: { codePanel: true },
},
};
export default preview;

Individual stories can still opt out with docs: { codePanel: false }, and the same cascade works at the component level via the meta’s parameters.

The “Show code” block under stories on a docs page needs no parameter at all — it is always available.

Set parameters.docs.source.code to show something specific instead — a manual snippet always wins:

export const Custom = {
parameters: {
docs: { source: { code: '<Card title="Handwritten" />' } },
},
};

Framework component stories (parameters.renderer set to react, vue, and so on) are left alone: they render through their own framework, so no Astro snippet is generated for them.

In static builds (storybook build), Astro components are pre-rendered at build time with their default args. The Controls panel cannot re-render them with different values.

The package handles this automatically: in a static build, all control inputs for Astro component stories are disabled and an ℹ️ Astro info row appears in the Controls table explaining that the component is pre-rendered. The props table itself still shows everything extracted from the component.

Controls work fully in dev mode (storybook dev) and for framework component stories (React, Vue, etc.) in all modes.

See Static Builds for more details.