Skip to content

Conversation

@dmytrokirpa
Copy link
Contributor

@dmytrokirpa dmytrokirpa commented Nov 6, 2025

This RFC proposes headless style hook variants for Fluent UI v9 components that remove default style implementations while preserving component behavior, accessibility, and base class names. This enables teams with custom design requirements to use Fluent UI as a foundation without being forced to override or work around default styles.

@github-actions
Copy link

github-actions bot commented Nov 6, 2025

Pull request demo site: URL

@dmytrokirpa dmytrokirpa force-pushed the experiment/unstyled_css branch from f81e722 to f2b8caf Compare November 6, 2025 13:30
@dmytrokirpa dmytrokirpa force-pushed the experiment/unstyled_css branch from f2b8caf to 2f51180 Compare November 7, 2025 15:21
@github-actions github-actions bot added Type: RFC Request for Feedback and removed NX: core labels Nov 7, 2025
@dmytrokirpa dmytrokirpa changed the title [DO NOT MERGE]: Experiment/unstyled css RFC: Unstyled/headless components Nov 7, 2025
@github-actions
Copy link

github-actions bot commented Nov 7, 2025

📊 Bundle size report

✅ No changes found

- ✅ Component files unchanged (still supports `useCustomStyleHook_unstable`)
-**~25% JS bundle size reduction** (tested) by excluding Griffel runtime

**Note:** To completely eliminate Griffel from an application, unstyled variants are needed for **all components that use Griffel**, including infrastructure components like `FluentProvider`. This ensures no Griffel runtime is bundled.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Unstyled variants are opt-in via bundler extension resolution (similar to [raw modules](https://storybooks.fluentui.dev/react/?path=/docs/concepts-developer-unprocessed-styles--docs#how-to-use-raw-modules), ensuring zero breaking changes.

**Performance Impact:** Internal testing shows **~25% JavaScript bundle size reduction** when using unstyled variants, as Griffel runtime and style implementations are excluded from the bundle.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will be an increase once you will have actual CSS that matches what we have currently? Nobody is going to use components without any styles.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It mainly depends on how much styles consumers want to use. Even if the number (25%) isn't completely accurate, users will still need to pay for any default styles they don't intend to use.

I tried to check the increase with the actual CSS version this by switching the style hook to CSS modules, but our tooling (monosize) only takes into account JS, not CSS. It's possible I missed a configuration or setting.

Copy link
Member

@layershifter layershifter Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to check the increase with the actual CSS version this by switching the style hook to CSS modules, but our tooling (monosize) only takes into account JS, not CSS. It's possible I missed a configuration or setting.

It indeed won't measure CSS by default.

IMO it's crucial to provide measurements to compare apples to apples. As currently, "25% reduction" is a false message.

Copy link
Contributor Author

@dmytrokirpa dmytrokirpa Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't call it a "false" message, as it clearly says "JavaScript bundle size".

Here is where I got data for comparisons:

  • Griffel + AOT + NO CSS extraction
image
  • Griffel + AOT + CSS extraction
image
  • "Unstyled" - No Griffel or default styles in JS, styles are in external CSS
image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got what you mean, however you won't use solely JavaScript, correct? 🐱 Otherwise cards would look like that:

image

P.S. In the Griffel scenario we will need have part of Griffel runtime + mappings for merging which contributes to JS size.


I've ran "Griffel + AOT + CSS extraction" scenario for Card.fixture.js:

  • JS 82.432 kB / 25.161 kB
  • CSS 13K / 2.52 kB
  • Total: 95.4 K / 27.6 K

Can you please provide the same for "Without Griffel - "unstyled" + plain CSS"? (considering that CSS file for Card should match current styles)

From the RFC, I noticed that the initial plan is to update 10 components, so it would good to have the same for all them to be realistic.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably provide a realistic sample implementation with different styles and use that for comparison. and make sure we measure it

Copy link
Contributor Author

@dmytrokirpa dmytrokirpa Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably provide a realistic sample implementation with different styles and use that for comparison. and make sure we measure it

Totally agree! Just to clarify, reducing bundle size wasn’t our only reason for this. The main goal is to support partners who have their own unique UI needs - like, their designs don’t use Fluent 2, they have specific tech/performance requirements, and they aren’t using Griffel or default Fluent styles. They’d rather not include stuff they don’t actually need.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked this in a Teams thread but putting it here as well. I'd be curious what runtime perf numbers look like after this change as well. Given JS is more expensive client side than something like static CSS. I know Griffel supports extraction so we should compare that too, but it would be good to have a full perf profile. Not sure if anything has been setup with tensile yet but that might be helpful in testing this kind of thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked this in a Teams thread but putting it here as well. I'd be curious what runtime perf numbers look like after this change as well. Given JS is more expensive client side than something like static CSS. I know Griffel supports extraction so we should compare that too, but it would be good to have a full perf profile. Not sure if anything has been setup with tensile yet but that might be helpful in testing this kind of thing.

That’s a good point! After giving it some thought, I feel like it’s probably outside the RFC’s scope. The main idea here isn’t to compare or change Fluent’s styling approach, but more about letting consumers fully provide their own styles instead of just overriding what’s already there.

**Pros:** Single source of truth, automatic
**Cons:** Complex build config, harder to debug

## Usage Examples
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you pls also include examples for styles applied conditionally based on state and/or props? for example how to style a toggle button with different background based on toggle state or how to style a secondary button?

Copy link
Contributor Author

@dmytrokirpa dmytrokirpa Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@dmytrokirpa dmytrokirpa changed the title RFC: Unstyled/headless components RFC: Headless Components Dec 10, 2025
@dmytrokirpa dmytrokirpa marked this pull request as ready for review December 10, 2025 14:10
@dmytrokirpa dmytrokirpa requested review from a team as code owners December 10, 2025 14:10
@layershifter
Copy link
Member

layershifter commented Dec 16, 2025

Thank you for this RFC. I would like to share some thoughts about this proposal.

For Who is This RFC?

*The main goal is to provide true flexibility for teams with custom design requirements:

This RFC appears to be designed for teams who want to restyle Fluent UI components without overriding the default styles. This is a valuable use case.

However, I would like to point out an important limitation of the current proposal. The component API remains unchanged, which means all design-related properties (such as appearance, size, etc.) are still part of the API. This means that customers cannot add, remove or modify these design terms without recomposing components in some way.

This is particularly important because the RFC describes these components as "headless" or "styleless", but they still have design opinions built into their API 🚨

import { Button } from "@fluentui/react-components"

function App() {
  return <Button appearance="primary" />
  //             ^ ⚠️  still valid for "headless" components
}

For comparison, libraries like Base UI (which are truly unstyled) do not include design terms in their component API i.e. don't provide opinions on design. They only expose component states (such as open, disabled, selected, etc.) and allow customers to define all design-related properties 💡

I would like to better understand which specific problem this RFC aims to solve. If the goal is to provide truly unstyled components, would it make sense to also remove design-related terms from the component API?

This question is important because it affects how flexible the solution can be for different use cases.

@layershifter
Copy link
Member

layershifter commented Dec 16, 2025

Bundler magic concerns

I have several concerns about using bundler configuration as the main solution for this feature.

Bundler configuration should be avoided when possible

The another RFC mentions (RFC: Stop pre-processing styles with Griffel in @fluentui/react-components) bundler configuration as a workaround solution to avoid breaking changes. Also, that RFC only targets Rspack & Webpack bundlers as only for them the whole CSS extraction pipeline in Griffel works.

I am surprised that this RFC proposes it as the primary approach. In general, it is better to avoid requiring users to modify their bundler configuration as it's a default solution in JS ecosystem 👆

1. Some tools do not allow configuration changes

For example, Create React App did not allow Webpack configuration changes without ejecting or using third-party tools like react-app-rewired. While modern tools are more flexible, this leads to the next concern.

2. Not all bundlers support this feature

The RFC shows examples for Vite and Webpack. However, there are many other bundlers in use today (Parcel, esbuild, Rspack, Rolldown, Turbopack, Rollup, etc.) including legacy solutions. We would need to verify that each bundler supports extension resolution in this way. We would also have to track support in future bundlers 💥

Additionally, some projects have separate bundler configurations for client and server builds, for local & CI builds.

3. Other tools in the build chain need configuration too

Even if all bundlers support this feature, other tools in the development chain also need to be configured. Test runners need the same configuration:

  • Vitest can reuse Vite configuration ✅
  • But what about Jest, Karma, Mocha, or other test runners? 🤔

Without proper configuration, tests could see different class names than the browser 🚨 For example:

  • Browser: .fui-Button--appearance-filled
  • Tests: .fui-Button with Griffel atomic classes

4. Cannot use both styled and unstyled components together

The bundler configuration approach has a critical limitation: it is impossible to opt-out at the module level. Because the configuration is global for the entire application including third party dependencies. It might be considered as a benefit, but it's not (see next replies).

With this approach you cannot use both styled and unstyled components from @fluentui/react-components at the same time.

For example, if a team wants to:

  • Use unstyled components for most of their application
  • Use default styled components only for specific sections where third party dependencies are used

This is not possible with the bundler configuration approach. The configuration applies to all bundle.

This limitation means teams cannot gradually migrate to unstyled components. They must either:

  • Use styled components for the entire application, or
  • Use unstyled components for the entire application

There is no middle ground 💣

Recommendation

We should provide a solution that works without requiring bundler configuration changes. The solution should:

  1. Work out of the box, regardless of the bundler or tools used
  2. Support tree-shaking (pay-as-you-go principle)
  3. Work correctly through the entire dependency chain
  4. Allow mixing styled and unstyled components when (and if) needed

This approach would be easier for teams to adopt and more future-proof.

@layershifter
Copy link
Member

Concerns about expected usage

I have concerns about how the proposed solution works in practice. The RFC does not fully explore the developer experience, and I believe the actual usage conflicts with some of the stated goals in it.

How will developers use unstyled components?

Let me walk through a typical scenario to illustrate the challenges.

Step 1: Initial setup

A team decides to use unstyled components from Fluent UI. They:

  1. Install @fluentui/react-components
  2. Configure their bundler (as described in the RFC)
  3. Components now have no styles applied ✅

Step 2: Adding styles

The team wants to use Button, Card and Divider. They create style files:

packages/app/src/Button.css
packages/app/src/Card.css
packages/app/src/Divider.css

Example style file:

/* Button.css */
.fui-Button {
    background-color: blue;
    color: white;
    /* other styles */
}

They then import these styles in their app:

// @filename packages/app/src/App.tsx
import { Button, Card, Divider } from '@fluentui/react-components';
import './Button.css';
import './Card.css';
import './Divider.css';

function App() {
  return (
    <Card>
      <Button>Click me</Button>
      <Divider />
    </Card>
  );
}

This works correctly ✅ However, this is just the beginning.

Step 3: Creating custom components

The team extracts logic into a custom component:

// @filename packages/app/src/MyCard.tsx
import { Card, Button, Divider } from '@fluentui/react-components';
import './Button.css';
import './Divider.css';
import './Card.css';
// ⬆️ they have to import CSS file in the component, too

export function MyCard() {
  return (
    <Card>
      <Button>Click me</Button>
      <Divider />
    </Card>
  );
}

Now, every file that uses Fluent UI components must manually import the corresponding CSS files. This quickly becomes tedious and error-prone in large codebases with many developers.

Problem 1: Manual style imports are error-prone

Developers can easily forget to import styles:

// @filename packages/app/src/MyButton.tsx
import { Button } from '@fluentui/react-components';
// Forgot to import './Button.css' ❌

export function MyButton() {
  return <Button>Click me</Button>;
}

This is especially problematic because the error may not be immediately visible. If another component has already imported the styles, the component will work 💥

// @filename packages/app/src/App.tsx
import { MyCard } from './MyCard';
import { MyButton } from './MyButton';

function App() {
  return (
    <>
      <MyCard />
      <MyButton /> {/* 🫲 Works because MyCard imported Button.css */}
    </>
  );
}

However, if components are lazy loaded, there will be a flash of unstyled content (FOUC):

// @filename packages/app/src/App.tsx
import { Suspense, lazy } from 'react';
import { MyButton } from './MyButton';

const MyCard = lazy(() => import('./MyCard'));

function App() {
  return (
    <>
      <Suspense fallback={<div>Loading...</div>}>
        <MyCard />
      </Suspense>
      <MyButton /> {/* 💥 FOUC - styles not loaded yet */}
    </>
  );
}

Linting rules could help enforce CSS files imports, but this is not a good developer experience 🚨

Problem 2: First-party and third-party components

What happens when first-party or third-party packages use @fluentui/react-components? 🤔

// @filename node_modules/first-party-package/src/SomeComponent.tsx
import { Button } from '@fluentui/react-components';

export function SomeComponent() {
  return <Button>Click me</Button>;
}

This component will have no styles unless the application imports Button.css somewhere 💥 The app developer may not even know that SomeComponent uses Fluent UI components.

To be safe, developers might load all styles at the app entry point:

// @filename packages/app/src/App.tsx
import { SomeComponent } from 'first-party-package';
import { MyCard } from './MyCard';

import './Button.css';
import './Card.css';
import './Divider.css';
// ... import all possible component styles `Menu`, `Popover`, `Carousel`, much more ⚠️ 

function App() {
  return (
    <>
      <MyCard />
      <SomeComponent />
    </>
  );
}

However, this defeats the "pay for what you use" principle. The app now loads styles for components that may not be used at all or not needed for the initial load.

Problem 3: Teams will likely recompose the design system

To avoid manual style imports, teams will likely create wrapper components:

// @filename packages/my-design-system/src/Button.tsx
import './Button.css';

export { Button } from '@fluentui/react-components';

Then use these wrappers in the app:

// @filename packages/app/src/App.tsx
import { Button } from 'my-design-system';

function App() {
  return <Button>Click me</Button>;
}

At this point, the team is maintaining their own design system that re-exports Fluent UI components. This is essentially the same as recomposing components, which is one of the problems the RFC aims to solve.

Note, that at this point the team can also tweak design terms used in their app.

Conflicts with RFC goals

These usage patterns conflict with the stated goals of the RFC:

Goal: "You are no longer forced to override or fight default styles—simply provide your own styling from scratch."

Reality: While technically true, developers must manually import styles in every file that uses components. This is not simple and becomes a maintenance burden. Or create their own design system...

Goal: "You only pay for what you use: if you don't need the default Fluent styles, they are not included in your bundle at all."

Reality: To avoid FOUC issues with third-party packages, developers may need to import all component styles at the app entry point, defeating the "pay as you go" principle.

Note: it's possible to say that imports of third-party packages that use Fluent UI will be prohibited, but is it real goal?

Goal: "This approach enables a clean separation of behavior/accessibility from visual design, making Fluent UI a better foundation for custom design systems."

Reality: Teams will likely need to recompose the design system anyway to avoid manual style imports everywhere. This recomposition work is what the RFC aims to eliminate.

Recommendation

The RFC should include a detailed section on the expected developer workflow and how styles should be managed. This section should address:

  1. How to handle CSS file imports?
  2. How to handle first-party and third-party packages that use Fluent UI?
  3. Whether recomposing the design system is the expected pattern?
  4. How to maintain the "pay as you go" principle in practice?

Without clear guidance on these points, teams may find the solution difficult to adopt successfully.

@layershifter
Copy link
Member

Compatibility concerns with Fluent UI ecosystem

I have concerns about how headless components will work with the existing Fluent UI ecosystem. The RFC does not fully address compatibility issues that may arise.

1. FluentProvider requires Griffel

The RFC mentions using FluentProvider for custom style hooks. However, FluentProvider itself is a styled component that uses Griffel (makeStyles) 💥

I have seen that #35537 features ThemelessFluentProvider, so the RFC should be updated.

2. Third-party packages likely use Griffel

When first-party or third-party packages depend on @fluentui/react-components, they typically use Griffel for style overrides:

// node_modules/first-party-package/src/SomeComponent.tsx
import { Button, makeStyles } from '@fluentui/react-components';

const useStyles = makeStyles({
  customButton: {
    /* custom styles */
  },
});

export function SomeComponent() {
  const styles = useStyles();

  return <Button className={styles.customButton}>Click me</Button>;
}

How will this work with headless components? The RFC does not explain this scenario.

Some concerns:

  1. Base styles are missing - Even if Griffel styles are applied, they may not work correctly because the base styles are not present
  2. CSS conflicts - There may be conflicts between Griffel-generated CSS and styles from CSS files or other styling solutions due to specificity or ordering 🚨
  3. Dependency restrictions - Does this mean teams using headless components cannot use any dependencies that rely on @fluentui/react-components?

3. Custom style hooks may not work as expected

Custom style hooks present another compatibility issue. The RFC keeps the same API and hook names (e.g., useButtonStyles_unstable), which creates the impression that custom style hooks will work the same way 🧐

However, in reality, the behavior will be different. Custom style hooks do not create a strict contract on styles inside them, which means:

  1. Developers may pass Griffel-based overrides to them (as they do today)
  2. These Griffel overrides may not work correctly with headless components

For example, @fluentui-contrib/react-cap-theme is built using custom style hooks. This package will not work with headless components 💥

Recommendation: Rename custom style hooks

I suggest renaming the custom style hooks to make it clear that they work differently:

export const Button = React.forwardRef((props, ref) => {
  const state = useButton_unstable(props, ref);

-  useCustomStyleHook_unstable('useButtonStyles_unstable')(state);
+  useCustomStyleHook_unstable('useBaseButtonStyles_unstable')(state);

  return renderButton_unstable(state);
});

This change would:

  1. Make it clear that the behavior is different from standard styled components
  2. Prevent developers from accidentally using Griffel-based overrides
  3. Help identify compatibility issues early in development

4. Design tokens and CSS variables are unclear

The RFC mentions that tokens are removed along with Griffel: "By omitting default styles and their dependencies (like Griffel and tokens), bundle size is reduced." However, the usage examples show usage of design tokens:

  • CSS example: var(--colorPrimaryBackground), var(--colorPrimaryForeground)
  • Griffel example: tokens.colorPrimaryBackground, tokens.colorPrimaryForeground
  1. Where do CSS variables come from? If tokens are removed, how are CSS variables like --colorPrimaryBackground defined?
  2. Are tokens actually removed? The Griffel example shows using tokens.colorPrimaryBackground, which suggests tokens are still available 🚨 This is especially a big issue, with the current proposal customers don't have a clear contract of what is not available.

The RFC should clarify:

  • Whether design tokens are available in headless mode
  • What exactly is removed when "tokens" are mentioned as a removed dependency

Caution

This point is also linked to point 2 about third party components. If tokens are used in styles overrides in such components and not available, how is it expected to work?

4. Zero breaking changes claim needs clarification

The RFC states: "Headless variants are opt-in via bundler extension resolution... ensuring zero breaking changes."

This claim is technically correct but misleading:

  • For teams not using headless mode: No breaking changes (they continue using styled components) ✅
  • For teams switching to headless mode: There are significant breaking changes:
    • FluentProvider should be removed
    • Third-party packages using @fluentui/react-components will not work correctly 🚨
    • Custom style hooks from packages like @fluentui-contrib/react-cap-theme will break 🚨
    • Existing styling patterns need to be completely rewritten and kept in mind ⚠️

The RFC should be more transparent about the breaking changes teams will face when adopting headless components.

Copy link
Member

@layershifter layershifter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-- comments on the PR --

@dmytrokirpa dmytrokirpa changed the title RFC: Headless Components RFC: Unstyled Components Dec 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: RFC Request for Feedback

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants