-
Notifications
You must be signed in to change notification settings - Fork 2.9k
RFC: Unstyled/headless components #35464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| ## 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we already have this: https://github.com/microsoft/fluentui-contrib/tree/main/packages/react-themeless-provider |
||
|
|
||
| ### 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here is an example for Button component: https://github.com/microsoft/fluentui/pull/35491/files#diff-87994f1431cce0df0f1b30e5980466aae99f5bab0c73f40d39266af17e977caa I'll add it to the doc |
||
|
|
||
| ### 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) | ||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 extractionGriffel + AOT + CSS extraction"Unstyled" - No Griffel or default styles in JS, styles are in external CSSThere was a problem hiding this comment.
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:
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:Can you please provide the same for "Without Griffel - "unstyled" + plain CSS"? (considering that CSS file for
Cardshould 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.
There was a problem hiding this comment.
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
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.