Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# RFC: Unstyled Components

## Contributors

- @dmytrokirpa

## Summary

This RFC proposes **unstyled style hook variants** that omit Griffel CSS implementations while preserving base class names (`.fui-[Component]`). This enables partners to use alternative styling solutions (CSS Modules, Tailwind, vanilla CSS) without recomposing components.

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.


## Problem Statement

Partners want to use Fluent UI v9 with alternative styling solutions but currently must:

1. Recompose every component manually (high maintenance)
2. Override styles via `className` props (fragile, specificity issues)
3. Use custom style hooks (still depends on Griffel runtime and default styles)

**Use cases:**

- Teams using CSS Modules, Tailwind CSS, or vanilla CSS
- Complete design system replacement while keeping Fluent behavior/accessibility
- Bundle size optimization: **~25% JS bundle size reduction** (tested on a few components) by removing Griffel runtime and style implementations

## Solution

Ship unstyled style hook variants with `.styles.unstyled.ts` extension, resolved via bundler configuration. The unstyled variant:

- ✅ Removes all Griffel `makeStyles`/`makeResetStyles` calls
- ✅ Preserves base class names (`.fui-Button`, `.fui-Button__icon`, etc.)
- ✅ Maintains identical hook signature
- ✅ 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.


### Example

**Standard style hook** (`useButtonStyles.styles.ts`):

```tsx
import { makeStyles, mergeClasses } from '@griffel/react';

export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' };

const useStyles = makeStyles({
root: {
/* extensive Griffel styles */
},
icon: {
/* icon styles */
},
});

export const useButtonStyles_unstable = (state: ButtonState) => {
const styles = useStyles();
state.root.className = mergeClasses(buttonClassNames.root, styles.root, state.root.className);
return state;
};
```

**Unstyled style hook** (`useButtonStyles.styles.unstyled.ts`):

```tsx
import { mergeClasses } from '@fluentui/react-utilities';

export const buttonClassNames = { root: 'fui-Button', icon: 'fui-Button__icon' };

export const useButtonStyles_unstable = (state: ButtonState) => {
// Only apply base class names, no styles
state.root.className = mergeClasses(buttonClassNames.root, state.root.className);
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like something that is missing here is that the style hooks are implicitly adding "state classes" when they apply styles based on component state (example).

I think we'll need to introduce well-known classes for these so users bringing their own styles can correctly respond to state changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just had another idea — not directly tied to this proposal, but it could help with the whole well-known classes issue. What if we exposed component state as data-attributes on DOM elements using the useComponentState hook? For example, we could set data-appearance="outline|underline|etc" and data-size="small|large|etc". That way, we could easily use these in styles, tests, and more. It also can eliminate some JS (runtime classes mapping)

Here are a couple of examples from other DS/UI libraries doing something similar:

return state;
};
```

**Component unchanged:**

```tsx
import { useButtonStyles_unstable } from './useButtonStyles.styles'; // ← Resolves to .unstyled.ts when configured

export const Button = React.forwardRef((props, ref) => {
const state = useButton_unstable(props, ref);
useButtonStyles_unstable(state); // ← Uses unstyled variant when configured
useCustomStyleHook_unstable('useButtonStyles_unstable')(state); // ← Still available
return renderButton_unstable(state);
});
```

### Bundler Configuration

**Webpack:**

```js
module.exports = {
resolve: { extensions: ['.unstyled.js', '...'] },
};
```

**Vite:**

```js
export default {
resolve: { extensions: ['.unstyled.js', '...'] },
};
```

**Next.js:**

```js
module.exports = {
webpack: config => {
config.resolve.extensions = ['.unstyled.js', ...config.resolve.extensions];
return config;
},
};
```

## Implementation

### Option A: Statically Generated Files (Recommended)

Generate `.styles.unstyled.ts` files and check them into the repository.

**Pros:** Simple, visible in codebase, easy to verify
**Cons:** Duplicate files to maintain

**Process:**

1. Scan for `use*Styles.styles.ts` files (including infrastructure components like `FluentProvider`)
2. Generate `use*Styles.styles.unstyled.ts` by:
- Keeping class name exports (`*ClassNames`)
- Keeping CSS variable exports (for reference)
- Removing all `makeStyles`/`makeResetStyles` calls
- Removing Griffel imports
- Simplifying hook to only apply base class names

### Option B: Build-Time Transform

Transform imports at build time via bundler plugin.

**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.


### CSS Modules

```css
/* Button.module.css */
:global(.fui-Button) {
padding: 8px 16px;
background-color: var(--primary-color);
color: white;
}
```

### Tailwind CSS

```css
/* Global CSS */
.fui-Button {
@apply px-4 py-2 bg-blue-500 text-white rounded;
}
```

### Custom Style Hook

```tsx
<FluentProvider
customStyleHooks_unstable={{
useButtonStyles_unstable: useCustomButtonStyles,
}}
>
<Button>Click me</Button>
</FluentProvider>
```

## Options Considered

### Option A: Unstyled Style Hooks via Extension Resolution (Chosen)

✅ Opt-in, zero breaking changes, follows raw modules pattern, component API unchanged
👎 Requires bundler configuration

### Option B: Separate Package

✅ Clear separation, no bundler config
👎 Another package to maintain, partners must change imports

### Option C: Runtime Flag

✅ No bundler config, can toggle dynamically
👎 Runtime overhead, Griffel still bundled

## Migration

**For standard users:** No changes required.

**For unstyled users:**

1. Configure bundler to resolve `.unstyled.js` extensions
2. Verify base class names (`.fui-*`) are applied
3. Apply custom CSS targeting `.fui-*` classes
4. Optionally use custom style hooks via `FluentProvider`

## Open Questions

1. **Preserve CSS variable exports?**
2. **Use `mergeClasses` in unstyled hooks?**
3. **Handle nested component styles?**
4. **Generate for styling utility hooks?**
5. **Keep unstyled variants in sync?** Automated tests + build-time validation?
6. **Keep `useCustomStyleHook_unstable`?**

## Testing Strategy

- Behavioral tests (excluding style assertions)
- Class name verification (`.fui-*` applied correctly)
- Snapshot tests (structure identical)
- Bundler integration tests (Webpack, Vite, Next.js)
- Accessibility tests (ARIA, keyboard navigation)
- Custom style hook tests

## Implementation Plan

### Phase 1: Proof of Concept

- [ ] Generate unstyled variants for 10 core components
- [ ] Test with Webpack and Vite
- [ ] Verify class names and custom hooks

### Phase 2: Build System

- [ ] Implement generation script
- [ ] Add sync validation
- [ ] Update CI

### Phase 3: Full Rollout

- [ ] Generate for all components (including infrastructure components like `FluentProvider`)
- [ ] Update documentation
- [ ] Add examples

### Phase 4: Maintenance

- [ ] Monitor issues
- [ ] Gather feedback

## References

- [Unprocessed Styles Documentation](https://react.fluentui.dev/?path=/docs/concepts-developer-unprocessed-styles--docs)