66
77## Summary
88
9- Introduce ` @fluentui/react-headless ` : unstyled components built from
10- ` use${ComponentName}Base_unstable ` + ` render${ComponentName}_unstable ` .
9+ We already have base hooks, render functions, and context value hooks.
10+ This RFC proposes the next abstraction layer: fully featured unstyled primitives for all
11+ convergence components so consumers do not need to wire ` useButtonBase ` + ` renderButton `
12+ for every usage, and so internal component state is exposed as stable ` data-* ` attributes
13+ for CSS targeting without direct hook access.
14+
15+ Introduce ` @fluentui/react-headless-components ` : unstyled components built from
16+ ` use${ComponentName}Base ` + ` render${ComponentName} ` , with stable state
17+ ` data-* ` attributes emitted by headless state hooks.
1118
1219Goal: give teams a supported, low-boilerplate path to Fluent behavior/accessibility with custom
1320visual design.
@@ -17,7 +24,7 @@ Headless components include:
1724- behavior and ARIA from base hooks
1825- existing slot API
1926- forward refs
20- - stable state ` data-* ` attributes on root slots
27+ - stable state ` data-* ` attributes on slots
2128
2229Headless components exclude:
2330
@@ -33,80 +40,137 @@ Use styled components when teams want Fluent visuals with branding tweaks.
3340Base hooks solve logic reuse but leave two gaps:
3441
35421 . Wiring gap: consumers repeat hook + render plumbing for each component.
36- 2 . Styling contract gap: there is no stable CSS-targeting contract for component state.
43+ 2 . State visibility gap at the component abstraction: when consumers use a full component
44+ (` <Button /> ` ) they do not get direct state access like they do in hooks, so they cannot
45+ reliably style based on internal state.
46+
47+ This RFC closes both:
48+
49+ - #1 via pre-wired headless components for all convergence components, removing repetitive
50+ hook + render wiring from app code
51+ - #2 via stable ` data-* ` attributes emitted by state hooks, keeping full-component DX
52+ while preserving state-driven styling
3753
38- This RFC closes both via pre-wired headless components and stable state attributes.
54+ ## Decision Drivers
55+
56+ - reduce repeated hook + render wiring for common component usage
57+ - provide a stable, documented state-to-attribute contract for CSS targeting
58+ - preserve accessibility behavior parity with styled components
3959
4060## Proposal
4161
4262### Package
4363
44- Ship headless components from ` @fluentui/react-headless ` .
64+ Ship headless components from ` @fluentui/react-headless-components ` .
4565
4666``` tsx
47- import { Button , Checkbox } from ' @fluentui/react-headless' ;
67+ import { Button , Checkbox } from ' @fluentui/react-headless-components ' ;
4868```
4969
70+ All convergence components are in scope. Simple components (Button, Checkbox, RadioButton,
71+ Toggle, Badge) ship first; compound/composite components (Menu, Dialog, Combobox, Select)
72+ follow in subsequent phases.
73+
5074Base hooks remain available from owning packages.
5175
5276### Composition Model
5377
54- Each headless component is hook + render only.
78+ Each headless component is hook + render only (and option context if it's a composite component) .
5579
5680``` tsx
57- import { useButtonBase_unstable , renderButton_unstable } from ' @fluentui/react-button ' ;
81+ import { useButtonBase , renderButton } from ' @fluentui/react-headless-components ' ;
5882
5983export const Button = React .forwardRef ((props , ref ) => {
60- const state = useButtonBase_unstable (props , ref );
61- return renderButton_unstable (state );
84+ const state = useButtonBase (props , ref );
85+ return renderButton (state );
6286});
6387```
6488
65- ### State Styling Contract (` data-* ` )
89+ This abstraction preserves the existing base architecture but removes repetitive wiring from app
90+ code. Note: headless components wrap existing hooks — they introduce no new behavior or
91+ reimplemented logic.
6692
67- State attributes are authored in base hooks and emitted by both headless and styled variants.
93+ Compound components wire sub-components and their shared context the same way styled variants
94+ do; the difference is that Griffel styles are omitted. Sub-components are exported from the
95+ package root alongside the parent (e.g. ` Menu ` , ` MenuTrigger ` , ` MenuPopover ` , ` MenuList ` ,
96+ ` MenuItem ` ).
6897
69- Core attributes:
98+ ### State To ` data-* ` Mapping (Primary Contract)
7099
71- | Attribute | Values |
72- | --------------------- | ---------------------------------- |
73- | ` data-disabled ` | presence |
74- | ` data-focusable ` | presence |
75- | ` data-checked ` | ` "true" ` \| ` "false" ` \| ` "mixed" ` |
76- | ` data-selected ` | presence |
77- | ` data-expanded ` | ` "true" ` \| ` "false" ` |
78- | ` data-open ` | ` "true" ` \| ` "false" ` |
79- | ` data-orientation ` | ` "horizontal" ` \| ` "vertical" ` |
80- | ` data-icon-position ` | ` "before" ` \| ` "after" ` |
81- | ` data-icon-only ` | presence |
82- | ` data-label-position ` | ` "before" ` \| ` "after" ` |
100+ Headless state hooks map internal state to stable ` data-* ` attributes. Components then render
101+ those attributes on slots so styling remains possible without direct hook state access.
83102
84- Rules :
103+ Example (simplified) :
85104
86- - these attributes represent base state only
87- - no design-state attributes (no appearance/size/shape)
88- - removal/rename is breaking (major)
89- - new attributes must be reviewed and documented in a shared schema file
90- - collisions with consumer-provided attributes are owned by base-hook defaults
105+ ``` tsx
106+ const state = useButtonBase (props , ref );
107+
108+ // stringifyDataAttribute: returns undefined (omits the attribute) for falsy presence
109+ // attributes, or the string value ("true"/"false"/enum) for boolean/tri-state attributes.
110+ Object .assign (state .root , {
111+ ' data-disabled' : stringifyDataAttribute (state .disabled ),
112+ ' data-disabled-focusable' : stringifyDataAttribute (state .disabledFocusable ),
113+ ' data-icon-only' : stringifyDataAttribute (state .iconOnly ),
114+ });
115+
116+ return state ;
117+ ```
91118
92- ### Compound Components
119+ Consumer styling:
120+
121+ ``` css
122+ .myButton [data-disabled ] {
123+ opacity : 0.5 ;
124+ }
125+
126+ .myButton [data-icon-only ] {
127+ padding-inline : 0.5rem ;
128+ }
129+ ```
93130
94- Compound components are provided as headless wrappers over existing base-context patterns.
131+ or with TailwindCSS:
95132
96133``` tsx
97- <Menu >
98- <MenuTrigger >
99- <Button >Open</Button >
100- </MenuTrigger >
101- <MenuPopover >
102- <MenuList >
103- <MenuItem >Cut</MenuItem >
104- </MenuList >
105- </MenuPopover >
106- </Menu >
134+ <Button className = " data-[disabled]:opacity-50 data-[icon-only]:px-2" />
107135```
108136
109- Provider misuse (for example ` MenuPopover ` outside ` Menu ` ) throws clear development errors.
137+ These attributes are authored in state hooks in ` @fluentui/react-headless-components ` and are
138+ treated as the stable styling surface for headless primitives.
139+
140+ Core attributes:
141+
142+ | Attribute | Values |
143+ | ------------------------- | ---------------------------------- |
144+ | ` data-disabled ` | presence |
145+ | ` data-disabled-focusable ` | presence |
146+ | ` data-focusable ` | presence |
147+ | ` data-checked ` | ` "true" ` \| ` "false" ` \| ` "mixed" ` |
148+ | ` data-selected ` | presence |
149+ | ` data-expanded ` | ` "true" ` \| ` "false" ` |
150+ | ` data-open ` | ` "true" ` \| ` "false" ` |
151+ | ` data-orientation ` | ` "horizontal" ` \| ` "vertical" ` |
152+ | ` data-icon-position ` | ` "before" ` \| ` "after" ` |
153+ | ` data-icon-only ` | presence |
154+ | ` data-label-position ` | ` "before" ` \| ` "after" ` |
155+
156+ Attribute emission rules:
157+
158+ - presence attributes are emitted only when the state is true; otherwise omitted
159+ - boolean-valued attributes are always emitted as ` "true" ` or ` "false" `
160+ - tri-state attributes (for example ` data-checked ` ) use the declared enum values
161+ - attributes are emitted on the root slot unless a documented component exception exists
162+
163+ Rules:
164+
165+ - these attributes represent base state only
166+ - no design-state attributes (no appearance/size/shape)
167+ - removal/rename is breaking (major)
168+ - adding a new attribute is non-breaking for CSS selectors; it may affect snapshot tests, which is acceptable
169+ - data attributes must be documented on the components documentation page (Storybook docsite)
170+ - base state attributes are reserved; if consumers provide the same ` data-* ` attribute, the base-hook value wins —
171+ this prevents state misrepresentation (a disabled button must not appear enabled regardless of consumer props)
172+ - precedence must be deterministic: apply reserved base-state attributes after consumer root props are resolved
173+ - ` data-* ` attributes are emitted as plain DOM attributes and are SSR-safe; no hydration concerns
110174
111175## Non-Goals
112176
@@ -126,44 +190,50 @@ Validation bar:
126190- focus management for overlays and composites
127191- automated a11y checks in component tests
128192
129- ## Versioning and Compatibility
130-
131- - ` @fluentui/react-headless ` depends on the source component packages it re-exports from.
132- - API drift is blocked by CI validation.
133- - breaking base-hook changes require coordinated major handling in headless exports.
134-
135- ## Migration
136-
137- - migration path is headless -> styled if teams converge on Fluent visuals later
138- - import-only switch is the primary path where slot structure/props are compatible
139- - styled -> headless is not a guaranteed no-refactor path
140-
141193## Testing Strategy
142194
143- Tests live with ` react-headless ` and cover:
195+ Tests live with ` react-headless-components ` and cover:
144196
145197- data attribute correctness across state combinations
146198- slot API parity with styled variants
147199- compound-component context/ref behavior
148200- accessibility and keyboard interaction
149201
150- ## Rollout
151-
152- - Phase 1: Button, Checkbox, Switch, Radio
153- - Phase 2: Menu, Dialog, Popover
154- - Phase 3: remaining interactive v9 components
155- - each phase exits only after parity tests and accessibility checks are green
156-
157202## Alternatives Considered
158203
159204### State classes
160205
161- Rejected: creates naming-coupling with styled layer and is less ergonomic than ` data-* ` selectors.
206+ ``` tsx
207+ <Button disabled disabledFocusable >
208+ Button
209+ </Button >
210+
211+ // Renders:
212+ // <button class="fui-Button--disabled fui-Button--disabledFocusable">Button</button>
213+ ```
214+
215+ Rejected: creates naming coupling between behavior state and styling implementation details,
216+ encourages internal class contracts, and diverges from a simple selector-based public contract.
162217
163218### Render props / callback className API
164219
165- Rejected: splits API from styled components and adds migration friction.
220+ ``` tsx
221+ <Button className = { state => ` fui-Button ${state .disabled ? ' fui-Button--disabled' : ' ' } ` } >Save</Button >
222+ ```
223+
224+ Rejected: introduces a different composition API from existing Fluent components, increases
225+ verbosity, and makes migration between headless and styled variants harder.
226+
227+ ### Style callback prop
228+
229+ ``` tsx
230+ <Button styles = { state => ({ root: { opacity: state .disabled ? 0.5 : 1 } })} />
231+ ```
232+
233+ Rejected: recreates a runtime styling API surface, increases API complexity, and does not align
234+ with the goal of exposing state through standard DOM selectors.
166235
167236## Decision
168237
169- Proceed with ` @fluentui/react-headless ` and data attributes as the stable state-styling contract.
238+ Proposed direction: proceed with ` @fluentui/react-headless-components ` and ` data-* ` attributes as the stable
239+ state-styling contract.
0 commit comments