diff --git a/code/lib/eslint-plugin/docs/rules/only-csf3.md b/code/lib/eslint-plugin/docs/rules/only-csf3.md
new file mode 100644
index 000000000000..6077469069af
--- /dev/null
+++ b/code/lib/eslint-plugin/docs/rules/only-csf3.md
@@ -0,0 +1,151 @@
+# Enforce CSF3 format for stories (only-csf3)
+
+[Component Story Format 3.0 (CSF3)](https://storybook.js.org/blog/component-story-format-3-0/) is the latest iteration of Storybook's story format, offering a simpler and more maintainable way to write stories. This rule enforces the use of CSF3 by identifying and reporting CSF2 patterns.
+
+
+
+**Included in these configurations**: N/A
+
+
+
+## Rule Details
+
+This rule aims to prevent the use of CSF2 patterns in story files and encourage migration to CSF3.
+
+Examples of **incorrect** code:
+
+```js
+// ❌ CSF2: Using Template.bind({})
+const Template = (args) => ;
+export const Primary = Template.bind({});
+Primary.args = { label: 'Primary' };
+
+// ❌ CSF2: Story function declaration
+export function Secondary(args) {
+ return ;
+}
+
+// ❌ CSF2: Story arrow function
+export const Tertiary = () => Click me ;
+
+// ❌ CSF2: Story with property assignments
+export const WithArgs = Template.bind({});
+WithArgs.args = { label: 'With Args' };
+WithArgs.parameters = { layout: 'centered' };
+
+// ❌ CSF2: Template.bind({}) with multiple stories
+const Template = (args) => ;
+
+export const Primary = Template.bind({});
+Primary.args = { label: 'Primary', variant: 'primary' };
+Primary.parameters = { backgrounds: { default: 'light' } };
+
+export const Secondary = Template.bind({});
+Secondary.args = { label: 'Secondary', variant: 'secondary' };
+Secondary.parameters = { backgrounds: { default: 'dark' } };
+```
+
+Examples of **correct** code:
+
+```js
+// ✅ CSF3: Object literal with args
+export const Primary = {
+ args: {
+ label: 'Primary',
+ },
+};
+
+// ✅ CSF3: Object literal with render function
+export const Secondary = {
+ render: (args) => Secondary ,
+};
+
+// ✅ CSF3: Multiple stories sharing render logic
+const render = (args) => ;
+
+export const Primary = {
+ render,
+ args: { label: 'Primary', variant: 'primary' },
+ parameters: { backgrounds: { default: 'light' } },
+};
+
+export const Secondary = {
+ render,
+ args: { label: 'Secondary', variant: 'secondary' },
+ parameters: { backgrounds: { default: 'dark' } },
+};
+```
+
+## When Not To Use It
+
+If you're maintaining a legacy Storybook project that extensively uses CSF2 patterns and cannot migrate to CSF3 yet, you might want to disable this rule.
+
+## Migration Examples
+
+Here are examples of how to migrate common CSF2 patterns to CSF3:
+
+1. Template.bind({}) with args:
+
+```js
+// ❌ CSF2
+const Template = (args) => ;
+export const Primary = Template.bind({});
+Primary.args = { label: 'Primary' };
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => ,
+ args: { label: 'Primary' },
+};
+```
+
+2. Function declaration stories:
+
+```js
+// ❌ CSF2
+export function Primary(args) {
+ return Primary ;
+}
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => Primary ,
+};
+```
+
+3. Story with multiple properties:
+
+```js
+// ❌ CSF2
+export const Primary = Template.bind({});
+Primary.args = { label: 'Primary' };
+Primary.parameters = { layout: 'centered' };
+Primary.decorators = [
+ (Story) => (
+
+
+
+ ),
+];
+
+// ✅ CSF3
+export const Primary = {
+ render: (args) => ,
+ args: { label: 'Primary' },
+ parameters: { layout: 'centered' },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+```
+
+## Further Reading
+
+- [Component Story Format 3.0](https://storybook.js.org/blog/component-story-format-3-0/)
+- [Migrating to CSF3](https://storybook.js.org/docs/migration-guide/from-older-version#csf-2-to-csf-3)
+- [Upgrading from CSF 2.0 to 3.0](https://storybook.js.org/docs/api/csf/index#upgrading-from-csf-2-to-csf-3)
+- [Writing Stories in Storybook](https://storybook.js.org/docs/writing-stories#component-story-format)
diff --git a/code/lib/eslint-plugin/src/index.ts b/code/lib/eslint-plugin/src/index.ts
index 40556e58fca3..aa146aad8f87 100644
--- a/code/lib/eslint-plugin/src/index.ts
+++ b/code/lib/eslint-plugin/src/index.ts
@@ -25,6 +25,7 @@ import noRendererPackages from './rules/no-renderer-packages';
import noStoriesOf from './rules/no-stories-of';
import noTitlePropertyInMeta from './rules/no-title-property-in-meta';
import noUninstalledAddons from './rules/no-uninstalled-addons';
+import onlyCsf3 from './rules/only-csf3';
import preferPascalCase from './rules/prefer-pascal-case';
import storyExports from './rules/story-exports';
import useStorybookExpect from './rules/use-storybook-expect';
@@ -57,6 +58,7 @@ export const rules = {
'no-stories-of': noStoriesOf,
'no-title-property-in-meta': noTitlePropertyInMeta,
'no-uninstalled-addons': noUninstalledAddons,
+ 'only-csf3': onlyCsf3,
'prefer-pascal-case': preferPascalCase,
'story-exports': storyExports,
'use-storybook-expect': useStorybookExpect,
diff --git a/code/lib/eslint-plugin/src/rules/only-csf3.test.ts b/code/lib/eslint-plugin/src/rules/only-csf3.test.ts
new file mode 100644
index 000000000000..e43927fcefed
--- /dev/null
+++ b/code/lib/eslint-plugin/src/rules/only-csf3.test.ts
@@ -0,0 +1,249 @@
+/** @file Enforce CSF3 format for stories */
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+import { AST_NODE_TYPES } from '@typescript-eslint/utils';
+import dedent from 'ts-dedent';
+
+import ruleTester from '../test-utils';
+import rule from './only-csf3';
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+ruleTester.run('only-csf3', rule, {
+ valid: [
+ // Simple CSF3 story
+ 'export const Primary = {}',
+
+ // CSF3 with args only (uses default render)
+ dedent`
+ export const Primary = {
+ args: {
+ primary: true,
+ label: 'Button',
+ },
+ }
+ `,
+
+ // CSF3 with render function only
+ dedent`
+ export const Secondary = {
+ render: (args) => ,
+ }
+ `,
+
+ // CSF3 with both render and args
+ dedent`
+ export const Tertiary = {
+ render: (args) => ,
+ args: {
+ label: 'Tertiary',
+ },
+ }
+ `,
+
+ // CSF3 meta export
+ dedent`
+ export default {
+ title: 'Button',
+ component: Button,
+ tags: ['autodocs'],
+ } satisfies Meta
+ `,
+
+ // CSF3 with play function
+ dedent`
+ export const WithInteractions = {
+ play: async ({ canvasElement }) => {
+ await userEvent.click(canvasElement.querySelector('button'))
+ }
+ }
+ `,
+
+ // Non-story exports should be ignored
+ dedent`
+ export const data = { foo: 'bar' }
+ export const utils = { format: () => {} }
+ `,
+
+ // Re-exports should be ignored
+ dedent`
+ export { Button } from './Button'
+ export * from './types'
+ `,
+
+ // Default export without CSF2 patterns
+ dedent`
+ export default function MyComponent() {
+ return Hello
+ }
+ `,
+ ],
+
+ invalid: [
+ // CSF2: Template.bind({}) with args
+ {
+ code: dedent`
+ const Template = (args) =>
+ export const Primary = Template.bind({})
+ Primary.args = { label: 'Button' }
+ `,
+ output: dedent`
+ const Template = (args) =>
+ export const Primary = {
+ render: Template,
+ args: { label: 'Button' },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Primary',
+ pattern: 'template bind',
+ },
+ type: AST_NODE_TYPES.CallExpression,
+ },
+ ],
+ },
+
+ // CSF2: Function declaration
+ {
+ code: dedent`
+ export function Secondary(args) {
+ return
+ }
+ `,
+ output: dedent`
+ export const Secondary = {
+ render: function(args) {
+ return
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Secondary',
+ pattern: 'function declaration',
+ },
+ type: AST_NODE_TYPES.FunctionDeclaration,
+ },
+ ],
+ },
+
+ // CSF2: Function expression
+ {
+ code: dedent`
+ export const Secondary = function(args) {
+ return Click me
+ }
+ `,
+ output: dedent`
+ export const Secondary = {
+ render: function(args) {
+ return Click me
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Secondary',
+ pattern: 'function expression',
+ },
+ type: AST_NODE_TYPES.FunctionExpression,
+ },
+ ],
+ },
+
+ // CSF2: Mixed with CSF3 (should detect both)
+ {
+ code: dedent`
+ export const Valid = {
+ args: { label: 'Valid' },
+ }
+ export function Invalid(args) {
+ return
+ }
+ `,
+ output: dedent`
+ export const Valid = {
+ args: { label: 'Valid' },
+ }
+ export const Invalid = {
+ render: function(args) {
+ return
+ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Invalid',
+ pattern: 'function declaration',
+ },
+ type: AST_NODE_TYPES.FunctionDeclaration,
+ },
+ ],
+ },
+
+ // CSF2: Property assignment mixed with CSF3
+ {
+ code: dedent`
+ export const Primary = {}
+ Primary.parameters = { foo: 'bar' }
+ `,
+ output: dedent`
+ export const Primary = {
+ parameters: { foo: 'bar' },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Primary',
+ pattern: 'property assignment (.parameters)',
+ },
+ type: AST_NODE_TYPES.AssignmentExpression,
+ },
+ ],
+ },
+
+ // CSF2: Complex story with multiple properties - preserves assignment order(render always first)
+ {
+ code: dedent`
+ export const Complex = Template.bind({})
+ Complex.args = { label: 'Complex' }
+ Complex.parameters = { layout: 'centered' }
+ Complex.decorators = [withActions]
+ Complex.play = async () => { /* test interactions */ }
+ `,
+ output: dedent`
+ export const Complex = {
+ render: Template,
+ args: { label: 'Complex' },
+ parameters: { layout: 'centered' },
+ decorators: [withActions],
+ play: async () => { /* test interactions */ },
+ }
+ `,
+ errors: [
+ {
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: 'Complex',
+ pattern: 'template bind',
+ },
+ type: AST_NODE_TYPES.CallExpression,
+ },
+ ],
+ },
+ ],
+});
diff --git a/code/lib/eslint-plugin/src/rules/only-csf3.ts b/code/lib/eslint-plugin/src/rules/only-csf3.ts
new file mode 100644
index 000000000000..7e4a805cadf7
--- /dev/null
+++ b/code/lib/eslint-plugin/src/rules/only-csf3.ts
@@ -0,0 +1,290 @@
+/**
+ * @file Enforce CSF3 format for stories.
+ * @see https://storybook.js.org/blog/component-story-format-3-0/
+ */
+import type { TSESTree } from '@typescript-eslint/utils';
+
+import { isIdentifier } from '../utils/ast';
+import { createStorybookRule } from '../utils/create-storybook-rule';
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+export = createStorybookRule({
+ name: 'only-csf3',
+ defaultOptions: [],
+ meta: {
+ type: 'problem',
+ severity: 'error',
+ docs: {
+ description: 'Enforce Component Story Format 3.0 (CSF3) for stories',
+ excludeFromConfig: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ noCSF2Format: 'Story "{{storyName}}" uses CSF2 {{pattern}}. Please migrate to CSF3.',
+ },
+ },
+
+ create(context) {
+ const sourceCode = context.sourceCode;
+ const textCache = new Map();
+ const storyNodes = new Map();
+
+ // Types
+ interface StoryInfo {
+ node: TSESTree.Node;
+ exportNode?: TSESTree.Node;
+ assignments: Assignment[];
+ isTemplateBind: boolean;
+ reported: boolean;
+ }
+
+ interface Assignment {
+ property: string;
+ value: TSESTree.Expression | TSESTree.Identifier;
+ node: TSESTree.AssignmentExpression | TSESTree.CallExpression;
+ }
+
+ //----------------------------------------------------------------------
+ // Helpers
+ //----------------------------------------------------------------------
+
+ const isStoryName = (name: string): boolean => {
+ // Fastest way to check uppercase first character
+ const firstChar = name.charCodeAt(0);
+ return firstChar >= 65 && firstChar <= 90;
+ };
+
+ const isTemplateBind = (node: TSESTree.Node): node is TSESTree.CallExpression => {
+ return (
+ node.type === 'CallExpression' &&
+ node.callee.type === 'MemberExpression' &&
+ node.callee.property.type === 'Identifier' &&
+ node.callee.property.name === 'bind'
+ );
+ };
+
+ const getNodeText = (node: TSESTree.Node): string => {
+ let cached = textCache.get(node);
+ if (!cached) {
+ cached = sourceCode.getText(node);
+ textCache.set(node, cached);
+ }
+ return cached;
+ };
+
+ const createCSF3Object = (story: StoryInfo): string => {
+ if (story.assignments.length === 0) {
+ return '{}';
+ }
+
+ const assignments = [...story.assignments];
+
+ // Handle Template.bind() case - add render property first
+ if (story.isTemplateBind && story.node.type === 'CallExpression') {
+ const callExpr = story.node;
+ if (
+ callExpr.callee.type === 'MemberExpression' &&
+ callExpr.callee.object.type === 'Identifier'
+ ) {
+ // Add render property as first property
+ assignments.unshift({
+ property: 'render',
+ value: callExpr.callee.object,
+ node: callExpr,
+ });
+ }
+ }
+
+ // Format properties - maintain order, with render first if present
+ const renderAssignment = assignments.find((a) => a.property === 'render');
+ const otherAssignments = assignments.filter((a) => a.property !== 'render');
+
+ const orderedAssignments = renderAssignment
+ ? [renderAssignment, ...otherAssignments]
+ : otherAssignments;
+
+ // Format properties
+ const props = orderedAssignments.map((a) => ` ${a.property}: ${getNodeText(a.value)},`);
+ return `{\n${props.join('\n')}\n}`;
+ };
+
+ const createFunctionCSF3 = (
+ name: string,
+ func:
+ | TSESTree.FunctionDeclaration
+ | TSESTree.FunctionExpression
+ | TSESTree.ArrowFunctionExpression
+ ): string => {
+ const params = func.params.map((p) => getNodeText(p)).join(', ');
+
+ // For arrow functions without block statement, wrap in block
+ if (func.body.type !== 'BlockStatement') {
+ const expr = getNodeText(func.body);
+ return `export const ${name} = {\n render: function(${params}) {\n return ${expr}\n },\n}`;
+ }
+
+ // For block statements, extract content and add proper indentation
+ const bodyText = getNodeText(func.body);
+ const bodyLines = bodyText.slice(1, -1).split('\n'); // Remove outer braces
+
+ // Process each line to add indentation, filtering out empty lines
+ const indentedLines = bodyLines
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0) // Remove empty lines
+ .map((line) => ` ${line}`);
+
+ // Join lines
+ const bodyContent = indentedLines.join('\n');
+
+ return `export const ${name} = {\n render: function(${params}) {\n${bodyContent}\n },\n}`;
+ };
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ return {
+ ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void {
+ const decl = node.declaration;
+ if (!decl) {
+ return;
+ }
+
+ // Function declarations
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
+ const name = decl.id.name;
+ if (!isStoryName(name)) {
+ return;
+ }
+
+ context.report({
+ node: decl,
+ messageId: 'noCSF2Format',
+ data: { storyName: name, pattern: 'function declaration' },
+ fix: (fixer) => fixer.replaceText(node, createFunctionCSF3(name, decl)),
+ });
+ return;
+ }
+
+ // Variable declarations
+ if (decl.type === 'VariableDeclaration') {
+ const [declarator] = decl.declarations;
+ if (!declarator?.id || !isIdentifier(declarator.id) || !declarator.init) {
+ return;
+ }
+
+ const name = declarator.id.name;
+ if (!isStoryName(name)) {
+ return;
+ }
+
+ const init = declarator.init;
+ const isFuncExpr =
+ init.type === 'FunctionExpression' || init.type === 'ArrowFunctionExpression';
+ const isObjExpr = init.type === 'ObjectExpression';
+ const isTemplBind = isTemplateBind(init);
+
+ // Function expressions
+ if (isFuncExpr) {
+ const funcExpr = init as TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression;
+ context.report({
+ node: init,
+ messageId: 'noCSF2Format',
+ data: { storyName: name, pattern: 'function expression' },
+ fix: (fixer) => fixer.replaceText(node, createFunctionCSF3(name, funcExpr)),
+ });
+ return;
+ }
+
+ // Track for later processing
+ if (isObjExpr || isTemplBind) {
+ let hasProps = false;
+ if (isObjExpr && init.type === 'ObjectExpression') {
+ hasProps = init.properties.length > 0;
+ }
+
+ if (isTemplBind || !hasProps) {
+ storyNodes.set(name, {
+ node: init,
+ exportNode: node,
+ assignments: [],
+ isTemplateBind: isTemplBind,
+ reported: false,
+ });
+ }
+ }
+ }
+ },
+
+ AssignmentExpression(node: TSESTree.AssignmentExpression): void {
+ if (
+ node.left.type !== 'MemberExpression' ||
+ !isIdentifier(node.left.object) ||
+ !isIdentifier(node.left.property)
+ ) {
+ return;
+ }
+
+ const name = node.left.object.name;
+ if (!isStoryName(name)) {
+ return;
+ }
+
+ const story = storyNodes.get(name);
+ if (story) {
+ story.assignments.push({
+ property: node.left.property.name,
+ value: node.right,
+ node,
+ });
+ story.reported = false;
+ }
+ },
+
+ 'Program:exit'(): void {
+ for (const [name, story] of storyNodes) {
+ if (story.reported || (!story.isTemplateBind && story.assignments.length === 0)) {
+ continue;
+ }
+
+ const lastAssign = story.assignments[story.assignments.length - 1];
+ const reportNode = story.isTemplateBind
+ ? story.node
+ : lastAssign?.node.type === 'AssignmentExpression'
+ ? lastAssign.node
+ : story.node;
+
+ context.report({
+ node: reportNode,
+ messageId: 'noCSF2Format',
+ data: {
+ storyName: name,
+ pattern: story.isTemplateBind
+ ? 'template bind'
+ : `property assignment (.${lastAssign?.property})`,
+ },
+ fix: (fixer) => {
+ const startNode = story.exportNode || story.node;
+ const endNode = lastAssign?.node || story.node;
+ const csf3Code = createCSF3Object(story);
+
+ if (!startNode.range || !endNode.range) {
+ return null;
+ }
+
+ return fixer.replaceTextRange(
+ [startNode.range[0], endNode.range[1]],
+ `export const ${name} = ${csf3Code}`
+ );
+ },
+ });
+ story.reported = true;
+ }
+ },
+ };
+ },
+});
diff --git a/docs/configure/integration/eslint-plugin.mdx b/docs/configure/integration/eslint-plugin.mdx
index 4f598a77a2dd..c781a87894da 100644
--- a/docs/configure/integration/eslint-plugin.mdx
+++ b/docs/configure/integration/eslint-plugin.mdx
@@ -167,6 +167,7 @@ This plugin does not support MDX files.
| [`storybook/no-stories-of`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | csf-strict flat/csf-strict |
| [`storybook/no-title-property-in-meta`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/no-title-property-in-meta.md) | Do not define a title in meta | ✅ | csf-strict flat/csf-strict |
| [`storybook/no-uninstalled-addons`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/no-uninstalled-addons.md) | This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name. | | recommended flat/recommended |
+| [`storybook/only-csf3`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/only-csf3.md) | Enforce Component Story Format 3.0 (CSF3) for stories | ✅ | N/A |
| [`storybook/prefer-pascal-case`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | ✅ | recommended flat/recommended |
| [`storybook/story-exports`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/story-exports.md) | A story file must contain at least one story export | | recommended flat/recommended csf flat/csf csf-strict flat/csf-strict |
| [`storybook/use-storybook-expect`](https://github.com/storybookjs/storybook/blob/next/code/lib/eslint-plugin/docs/rules/use-storybook-expect.md) | Use expect from `@storybook/test`, `storybook/test` or `@storybook/jest` | ✅ | addon-interactions flat/addon-interactions recommended flat/recommended |