Skip to content

Conversation

@Chiman2937
Copy link
Contributor

@Chiman2937 Chiman2937 commented Nov 10, 2025

  • Add top-level weight and style to @font-face when src is a string
  • Exclude variable font weight ranges from className (only static weights)
  • Align with Next.js localFont behavior

Closes #32992

What I did

Fixed incorrect CSS generation when using next/font/local with top-level weight and style properties alongside a string src.

Problem

Storybook only processes weight and style when they are specified inside the src array:

✅ Works in Storybook (array-style):

export const primary = localFont({
  src: [{ path: '../assets/fonts/PretendardVariable.woff2', weight: '45 920' }],
  variable: '--font-primary',
  display: 'swap',
});

❌ Broken in Storybook (top-level properties):

export const primary = localFont({
  src: '../assets/fonts/PretendardVariable.woff2', //variable font
  variable: '--font-primary',
  display: 'swap',
  weight: '45 920', //variable weight
});

However, Next.js supports both patterns. When using top-level weight/style properties with a string src, Storybook generates incorrect CSS:

<style id="font-face-font-e25342">
  @font-face {
    font-family: font-e25342;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    /* ❌ Missing font-weight: 45 920; */
  }
</style>
<style id="classnames-font-e25342">
  .font-e25342-45 920 { /* ❌ Invalid className with weight range */
    font-family: font-e25342;

    font-weight: 45 920; /* ❌ Invalid - should be in @font-face only */
  }

  .__variable_font-e25342-45 920 { // invalid ClassName
    --font-primary: 'font-e25342';
  }
</style>

Changes

  1. getFontFaceCSS function: Add weight and style to @font-face when src is a string

  2. getCSSMeta function: Filter out variable font weight ranges from className (only apply static weights)

  3. getClassName function: Replace spaces with hyphens in weight ranges to generate valid CSS class names

ex) .font-e25342-45 920 => .font-e25342-45-920

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

1. Test environment:

  • Create a Next.js 15 project with @storybook/nextjs
  • Use variable font (ex. weight range: 100-900)

Configure localFont with top-level weight (string src)

   export const primary = localFont({
     src: '../assets/fonts/PretendardVariable.woff2',
     variable: '--font-primary',
     display: 'swap',
     weight: '45 920', // Variable font weight range
   });

2. Verify the fix

  • Open browser DevTools → Elements tab
  • Inspect <style id="font-face-..."> tag
  • Before fix: font-weight is missing from @font-face
  • After fix: font-weight: 45 920; is present in @font-face
  • Verify .className does NOT include font-weight (correct for variable fonts)
✔️ Test Results

All test cases now match Next.js behavior - variable fonts work correctly with top-level weight property, and Storybook generates identical CSS output to Next.js App.

Case 1: Variable font with top-level weight range

export const primary = localFont({
  src: '../assets/fonts/PretendardVariable.woff2',
  variable: '--font-primary',
  display: 'swap',
  weight: '45 920',
});

Result - Next.js App

@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 45 920;
}

@font-face {
  font-family: 'primary Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0%;
  size-adjust: 101.55%;
}

.className {
  font-family: 'primary', 'primary Fallback';
}
.variable {
  --font-primary: 'primary', 'primary Fallback';
}

Result - Storybook

<style id="font-face-font-e25342">
  @font-face {
    font-family: font-e25342;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 45 920;
  }
</style>
<style id="classnames-font-e25342">
  .font-e25342-45-920 {
    font-family: font-e25342;
  }

  .__variable_font-e25342-45-920 {
    --font-primary: 'font-e25342';
  }
</style>

Case 2: top-level static weight

export const primary = localFont({
  src: '../assets/fonts/PretendardVariable.woff2',
  variable: '--font-primary',
  display: 'swap',
  weight: '100',
});

Result - Next.js App

@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 100;
}

@font-face {
  font-family: 'primary Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0%;
  size-adjust: 101.55%;
}

.className {
  font-family: 'primary', 'primary Fallback';
  font-weight: 100;
}
.variable {
  --font-primary: 'primary', 'primary Fallback';
}

Result - Storybook

<style id="font-face-font-e25342">
  @font-face {
    font-family: font-e25342;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 100;
  }
</style>
<style id="classnames-font-e25342">
  .font-e25342-100 {
    font-family: font-e25342;

    font-weight: 100;
  }

  .__variable_font-e25342-100 {
    --font-primary: 'font-e25342';
  }
</style>

Case 3: Variable font with array-style src and weight range

export const primary = localFont({
  src: [{ path: '../assets/fonts/PretendardVariable.woff2', weight: '45 920' }],
  variable: '--font-primary',
  display: 'swap',
});

Result - Next.js App

@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 45 920;
}

@font-face {
  font-family: 'primary Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0%;
  size-adjust: 101.55%;
}

.className {
  font-family: 'primary', 'primary Fallback';
}
.variable {
  --font-primary: 'primary', 'primary Fallback';
}

Result - Storybook

<style id="font-face-font-7b9d81">
  @font-face {
    font-family: font-7b9d81;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 45 920;
  }
</style>
<style id="classnames-font-7b9d81">
  .font-7b9d81 {
    font-family: font-7b9d81;
  }

  .__variable_font-7b9d81 {
    --font-primary: 'font-7b9d81';
  }
</style>

Case 4: Multiple static fonts with array-style src

export const primary = localFont({
  src: [
    {
      path: '../assets/fonts/PretendardVariable.woff2',
      weight: '100',
    },
    {
      path: '../assets/fonts/PretendardVariable.woff2',
      weight: '200',
    },
    {
      path: '../assets/fonts/PretendardVariable.woff2',
      weight: '300',
    },
  ],
  variable: '--font-primary',
  display: 'swap',
});

Result - Next.js App

@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 100;
}
@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 200;
}
@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 300;
}

@font-face {
  font-family: 'primary Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0%;
  size-adjust: 101.55%;
}

.className {
  font-family: 'primary', 'primary Fallback';
}
.variable {
  --font-primary: 'primary', 'primary Fallback';
}

Result - Storybook

<style id="font-face-font-bdfd50">
  @font-face {
    font-family: font-bdfd50;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 100;
  }
  @font-face {
    font-family: font-bdfd50;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 200;
  }
  @font-face {
    font-family: font-bdfd50;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 300;
  }
</style>
<style id="classnames-font-bdfd50">
  .font-bdfd50 {
    font-family: font-bdfd50;
  }

  .__variable_font-bdfd50 {
    --font-primary: 'font-bdfd50';
  }
</style>

Case 5: Variable font with top-level weight range and style

export const primary = localFont({
  src: '../assets/fonts/PretendardVariable.woff2',
  variable: '--font-primary',
  display: 'swap',
  weight: '45 920',
  style: 'italic',
});

Result - Next.js App

@font-face {
  font-family: 'primary';
  src: url('@vercel/turbopack-next/internal/font/local/font?{%22path%22:%22../assets/fonts/PretendardVariable.woff2%22,%22preload%22:true,%22has_size_adjust%22:true}')
    format('woff2');
  font-display: swap;
  font-weight: 45 920;
  font-style: italic;
}

@font-face {
  font-family: 'primary Fallback';
  src: local('Arial');
  ascent-override: 93.76%;
  descent-override: 23.75%;
  line-gap-override: 0%;
  size-adjust: 101.55%;
}

.className {
  font-family: 'primary', 'primary Fallback';
  font-style: italic;
}
.variable {
  --font-primary: 'primary', 'primary Fallback';
}

Result - Storybook

<style id="font-face-font-e25342">
  @font-face {
    font-family: font-e25342;
    src: url(./src/assets/fonts/PretendardVariable.woff2);
    font-weight: 45 920;
    font-style: italic;
  }
</style>
<style id="classnames-font-e25342">
  .font-e25342-italic-45-920 {
    font-family: font-e25342;
    font-style: italic;
  }

  .__variable_font-e25342-italic-45-920 {
    --font-primary: 'font-e25342';
  }
</style>

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

  • Bug Fixes
    • Improved font-weight and font-style CSS generation for local fonts with proper conditional rendering
    • Fixed font-weight class name generation to consistently normalize spacing in font weight values
    • Enhanced consistency in font property handling across different font configuration scenarios

- Add top-level weight and style to @font-face when src is a string
- Exclude variable font weight ranges from className (only static
  weights)
- Align with Next.js localFont behavior
@Chiman2937 Chiman2937 changed the title fix(nextjs): support top-level weight/style in localFont with string src fix(nextjs): support top-level weight/style in next/font/local with string src Nov 10, 2025
@valentinpalkovic valentinpalkovic changed the title fix(nextjs): support top-level weight/style in next/font/local with string src Nextj.js: Support top-level weight/style in next/font/local with string src Nov 10, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 10, 2025

📝 Walkthrough

Walkthrough

The changes update font handling in Next.js webpack loader. The first adds conditional font-weight and font-style CSS declarations for local font paths. The second conditionalizes font-weight CSS generation and normalizes weight values by replacing spaces with dashes in class names.

Changes

Cohort / File(s) Summary
Font face declarations
code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts
Adds conditional insertion of font-weight and font-style lines in the single-string local font path branch when weight and/or style are present, aligning with existing per-weight/style handling.
CSS metadata generation
code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts
Font-weight CSS generation now conditional (only when weights are valid and first weight contains no space). Class name construction normalizes spaces to dashes in weight values via replaceAll. Weight extraction in getClassName applies the same normalization.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Review conditional logic for font-weight/font-style emission in single-string font path handling
  • Verify space-to-dash normalization is applied consistently across class name and style inference paths
  • Confirm alignment between the two updated modules and absence of edge cases with weight/style combinations
✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fca6e7d and e8786c8.

📒 Files selected for processing (2)
  • code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts (1 hunks)
  • code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,jsx,json,html,ts,tsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{js,jsx,json,html,ts,tsx,mjs}: Run Prettier formatting on changed files before committing
Run ESLint on changed files and fix all errors/warnings before committing (use yarn lint:js:cmd <file>)

Files:

  • code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts
  • code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts
**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Export functions from modules when they need to be unit-tested

Files:

  • code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts
  • code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts
code/**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

In application code, use Storybook loggers instead of console.* (client code: storybook/internal/client-logger; server code: storybook/internal/node-logger)

Files:

  • code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts
  • code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts
{code/**,scripts/**}/**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Do not use console.log, console.warn, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size

Files:

  • code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts
  • code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: normal
🔇 Additional comments (4)
code/frameworks/nextjs/src/font/webpack/loader/local/get-font-face-declarations.ts (1)

49-50: LGTM! Correctly adds font-weight and font-style to @font-face for string sources.

These lines provide parity with the array source path (lines 61-62), ensuring that top-level weight and style properties are included in the generated @font-face rule when using a string src. This correctly places variable font ranges in @font-face rather than class rules.

code/frameworks/nextjs/src/font/webpack/loader/utils/get-css-meta.ts (3)

18-22: LGTM! Correctly excludes variable font ranges from class rules.

The additional check !options.weights[0]?.includes(' ') ensures that variable font weight ranges (e.g., "45 920") are excluded from the class CSS rules and only appear in the @font-face declaration. This prevents invalid font-weight values in class selectors while maintaining proper support for static weights.


46-46: LGTM! Correctly normalizes weight for valid CSS class names.

Replacing spaces with hyphens prevents invalid CSS class selectors. For example, a variable font range "45 920" becomes "45-920" in the class name, transforming font-45 920 (invalid) to font-45-920 (valid). The optional chaining handles undefined weights gracefully.

Note: Line 54 correctly uses the raw weights[0] for the inline style object, since CSS font-weight property values can legitimately contain spaces.


46-46: No issues found. Weight normalization is applied correctly and consistently.

The verification confirms the code handles two distinct contexts appropriately:

  • Line 46: Normalization applied for class name construction (where spaces must be removed)
  • Lines 19-20 and 54: CSS property values used without normalization (where spaces are valid)

The search revealed no problematic cases where weight normalization is missing from class name construction. The current implementation is correct.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@storybook-app-bot
Copy link

storybook-app-bot bot commented Nov 10, 2025

Package Benchmarks

Commit: 8a2bdcc, ran on 1 December 2025 at 14:09:14 UTC

No significant changes detected, all good. 👏

@github-actions github-actions bot added the Stale label Nov 25, 2025
@valentinpalkovic valentinpalkovic merged commit d6a9d22 into storybookjs:next Dec 1, 2025
53 of 54 checks passed
@valentinpalkovic
Copy link
Contributor

@Chiman2937
Copy link
Contributor Author

Hi @Chiman2937

Thank you for contributing! LGTM!

Would you be interested in fixing the bug also for @storybook/nextjs-vite?

https://github.com/storybookjs/vite-plugin-storybook-nextjs/blob/main/src/plugins/next-font/local/get-font-face-declarations.ts

Hi @valentinpalkovic

Of course!
I'll submit a PR for that soon.
Thank you! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Invalid class names for variable fonts with weight property (next/font/local)

3 participants