docs/react-v9/contributing/rfcs/react-components/convergence/custom-styling.md
Fluent UI React v9 is built today to adopt the Fluent 2 design as its default style. We strongly recommend that Microsoft applications leverage the default style and limit customization to a brand-based theme.
Currently, v9 has mechanisms to support several customization scenarios:
classname prop => Customize the style of one instance of a componentSee the appendix for more detailed analysis of the capabilities and limitations of each of these mechansisms.
Partners migrating from v0/v8 to v9 have brought up that v9's custom styling does not provide the same granularity of customization as previous versions. See the appendix for detailed partner use cases and scenarios.
Partners producing component libraries or component design surfaces are the most concerned about the lack of customization. They would like to customize the style of a single type of component without changing the style of other components.
Example: Change the border radius and border width of all Button components without changing those styles for Input components.
We chose to define a set of custom style hooks available on React context.
A CustomStyleHook takes state and updates the className to customize styles.
The type of the state is unknown to avoid circular dependencies between shared context and component packages,
and to force casting to a known component state type.
type CustomStyleHook = (state: unknown) => void;
The CustomStyleHooksContext provides the set of hooks for all the components exported from react-components.
export type CustomStyleHooksContextValue = Partial<{
useAccordionHeaderStyles_unstable: CustomStyleHook;
//...
};
export const CustomStyleHooksContext = React.createContext<CustomStyleHooksContextValue | undefined>(undefined);
Initially every hook had to be defined and the default was a large object of no-op methods. This bloated bundle size, so the context value was set to be partial, the default is undefined, and an accessor hook was defined to get a single custom style hook. If the hook is not defined, a no-op function is returned.
The custom style hooks are set using FluentProvider. When providers are nested, a shallow merge is done on the
hooks object allowing for inheritance and overrides.
export type FluentProviderProps = Omit<ComponentProps<FluentProviderSlots>, 'dir'> & {
//...
customStyleHooks_unstable?: FluentProviderCustomStyleHooks;
//...
};
Components call the useCustomStyleHooks_unstable to access a custom style hook and then call it after the default styling hook. The default and custom style hooks are always called to avoid conditional hook calls.
export const Button: ForwardRefComponent<ButtonProps> = React.forwardRef((props, ref) => {
//...
useButtonStyles_unstable(state);
useCustomStyleHook_unstable('useButtonStyles_unstable')(state);
//...
});
Each component could check context for a useCustomStyle hook within the useComponentStyle hook. If this hook was defined, it is called at the end of the method before returning state.
These example use Component as a substitute for a component like Button or Avatar.
const customStyles = useContextSelector(ComponentCustomStylesContext, context => context?.useCustomStyles);
//...component styling here
return customStyles ? customStyles(state) : state;
Each component defines own types for props and state, so would need to define its own context value types, context, and context provider.
export type ComponentCustomStylesContextValue = {
useCustomStyles?: (state: ComponentState) => ComponentState;
};
export const ComponentCustomStylesContext: Context<ComponentCustomStylesContextValue> =
createContext<ComponentCustomStylesContextValue>({});
export const ComponentCustomStylesContextProvider = ComponentCustomStylesContext.Provider;
When a caller wanted to customize the component, they use the provider. Callers would be able to control custom styling scope just like Fluent Provider. While they would need to use a provider per custom styled component, these could be aggregated into a component that transcludes children.
<App>
<FluentProvider theme={webLightTheme}>
<ComponentCustomStylesContextProvider value={{ useCustomStyles }}>
<Component>Hello custom styles!</Component>
</ComponentCustomStylesContextProvider>
</FluentProvider>
</App>
It would be nice to have a single method on context that could apply for all components. This proves difficult with each component having its own props and state types.
Another approach would be to define single context value type in the react-shared-contexts package that had a method for each component, but that causes a circular dependency problem for components.
There may some typescript trickery that would allow for a single context value type, but having each component define the context type it will inspect for custom styling follows SOLID dependency inversion.
A global object would be defined having custom styling hooks for each known component.
To avoid circular references and preventing tree-shaking, each hook takes state as unknown.
export type FluentStyleCustomizer = {
useCustomButtonStyles_unstable: (state: unknown) => void;
};
The global will have no-op methods defined so components can call unconditionally.
export const fuiCustomizer: FuiCustomizer = {
useCustomButtonStyles_unstable: () => {},
//...
};
Components would call the custom styling hook immediately after their own styling hook.
export const Button: ForwardRefComponent<ButtonProps> = React.forwardRef((props, ref) => {
const state = useButton_unstable(props, ref);
useButtonStyles_unstable(state);
fuiCustomizer.useButtonCustomStyles_unstable(state);
return renderButton_unstable(state);
}) as ForwardRefComponent<ButtonProps>;
Callers can customize styles by replacing the default hook for a component.
Callers are encouraged to use Griffel to define styles. They can use makeStyles and mergeClasses to default, override, or replace. The classes applied by the component style hook.
A good implementation will switch to typesafe methods.
export const useFancyButtonStyles = (state: unknown) => {
const styles = useStyles();
const buttonState = state as ButtonState;
buttonState.root.className = mergeClasses(
buttonState.root.className,
styles.root,
buttonState.size === 'small' && styles.small,
buttonState.size === 'medium' && styles.medium,
buttonState.size === 'large' && styles.large,
);
};
When the application is created, the customizer hooks can be set.
const rootElement = document.getElementById('root');
const root = createRoot(rootElement!);
fuiCustomizer.useButtonCustomStyles = useFancyButtonStyles;
root.render(<App />);
A blend of using React.Context from A1 and having a tree-shakable hooks object from A2.
This defines the same hooks object from A2. I've renamed it to ComponentStyleHooks as it has the same feel as the ComponentStyles in v8 that were attached to v8's Theme.
export type ComponentStyleHooks = {
useCustomButtonStyles_unstable: (state: unknown) => void;
};
As in A2, a default is defined with noop implementation.
export const defaultComponentStyleHooks: ComponentStyleHooks = {
useCustomButtonStyles_unstable: () => {},
};
A context and provider similar to other react-shared-context objects would be defined.
An additional componentStyles param would be added to FluentProvider.
π‘ We can optionally only allow componentStyles to be set at the top-most FluentProvider if we want to avoid confusion with the behavior of nested componentStyles.
π‘ We could also put the componentStyles on the theme object.
export type ComponentStylesContextValue = ComponentStyleHooks;
const ComponentStylesContext = React.createContext<ComponentStylesContextValue | undefined>(
undefined,
) as React.Context<ComponentStylesContextValue>;
export const Provider = ComponentStylesContext.Provider;
export function useComponentStyles(): ComponentStylesContextValue {
return React.useContext(ComponentStylesContext) ?? defaultComponentStyleHooks;
}
Like in option A1, components use the context to call the hooks. Because of the default, the call does not have to be conditional.
export const Button: ForwardRefComponent<ButtonProps> = React.forwardRef((props, ref) => {
const state = useButton_unstable(props, ref);
useButtonStyles_unstable(state);
const componentStyles = useComponentStyles();
componentStyles.useCustomButtonStyles_unstable(state);
return renderButton_unstable(state);
}) as ForwardRefComponent<ButtonProps>;
Like option A2, callers can define their custom style hooks object, but rather than replacing a global object, they pass it to FluentProvider.
export const useFancyButtonStyles = (state: unknown) => {
const styles = useStyles();
const buttonState = state as ButtonState;
buttonState.root.className = mergeClasses(
buttonState.root.className,
styles.root,
buttonState.size === 'small' && styles.small,
buttonState.size === 'medium' && styles.medium,
buttonState.size === 'large' && styles.large,
);
};
const customStyles : ComponentStyleHooks = {
useCustomButtonStyles_unstable = useFancyButtonStyles;
}
<App>
<FluentProvider theme={webLightTheme} componentStyles={customStyles}>
<Component>Hello custom styles!</Component>
</FluentProvider>
</App>
The hooks composition model was architected to separate concerns of component behavior, style, and rendering. Rather than provide an additional mechanism for custom styling, this option would assist partners with leveraging existing hooks to recompose components with a different styling mechanism.
Each component would be recomposed.
For example, imagine this Button.tsx is defined in a partner component library to export a custom styled button.
The component exported is named Button so that it can replace Fluent UI's Button.
The partner would define the useCustomButtonStyles hooks to style the component however they like. This could include exposing a similar centralized custom styling mechanism as in Option A or applying styles through a different mechanism than Griffel (such as SASS, emotion, or tailwind).
import * as React from 'react';
import { renderButton_unstable, useButton_unstable } from '@fluentui/react-components';
import type { ButtonProps, ForwardRefComponent } from '@fluentui/react-components';
import { useCustomButtonStyles } from '../styling/button';
export const Button: ForwardRefComponent<ButtonProps> = React.forwardRef((props, ref) => {
const state = useButton_unstable(props, ref);
useCustomButtonStyles(state);
return renderButton_unstable(state);
}) as ForwardRefComponent<ButtonProps>;
Button.displayName = 'Button';
Recomposing every component could be made easier with a compose method that hides some of the details of the ForwardRef and sequencing the hook.
import * as React from 'react';
import { compose } from '@fluentui/react-components';
import type { ButtonProps } from '@fluentui/react-components';
import { useCustomButtonStyles } from '../styling/button';
// This is a rough idea of what a compose method might look like.
export const Button = compose<ButtonProps>('Button', useButton_unstable, useCustomButtonStyles, renderButton_unstable);
Ben Howell is working on a proof-of-concept for this compose method. It is likely something worth doing regardless of the outcome of this RFC. The compose method could be called from our own components to reduce boilerplate code.
We could create an nx generator that produces all the boilerplate component recomposition code. This would allow partners to create an unstyled or custom library easily. Partners could re-run the tool to keep up with new components shipping in react-components.
yarn create-recomposed-library my-components
// Button with component tokens and global fallbacks
const ButtonTokens = {
background: '--fui-button-background',
color: '--fui-button-color',
border: '--fui-button-complex-selector',
};
const useButtonStyles = makeResetStyles({
backgroundColor: `var(${ButtonTokens.background}, ${tokens.colorBrandBackground})`,
color: `var(${ButtonTokens.color}, ${tokens.colorNeutralForegroundOnBrand})`,
border: '4px solid',
':enabled:not(:checked):not(:indeterminate)': {
borderColor: `var(${ButtonTokens.border}, orange)`,
},
});
const Button = props => {
const styles = useButtonStyles();
return (
<button {...props} className={mergeClasses(styles, props.className)}>
{props.children}
</button>
);
};
// Customizing button via className
const useCustomButtonStyle = makeResetStyles({
[ButtonTokens.background]: 'red',
[ButtonTokens.color]: 'white',
[ButtonTokens.border]: 'green',
':hover': {
[ButtonTokens.background]: 'green',
[ButtonTokens.color]: 'pink',
[ButtonTokens.border]: 'blue',
},
':active': {
[ButtonTokens.background]: 'orange',
[ButtonTokens.color]: 'black',
[ButtonTokens.border]: 'purple',
},
});
export const CustomButton = props => {
const rootStyle = useCustomButtonStyle();
return <Button className={rootStyle}>Hello</Button>;
};
// making changes in composition
const useCustomButtonStyle = makeResetStyles({
[ButtonTokens.background]: 'red',
[ButtonTokens.color]: 'white',
[ButtonTokens.border]: 'green',
':hover': {
[ButtonTokens.background]: 'green',
[ButtonTokens.color]: 'pink',
[ButtonTokens.border]: 'blue',
},
':active': {
[ButtonTokens.background]: 'orange',
[ButtonTokens.color]: 'black',
[ButtonTokens.border]: 'purple',
},
});
export const CustomButton: ForwardRefComponent<ButtonProps> = React.forwardRef((props, ref) => {
const state = useButton_unstable(props, ref);
const rootStyle = useCustomButtonStyle();
state.root.className = rootStyle;
useButtonStyles_unstable(state);
return renderButton_unstable(state);
// Casting is required due to lack of distributive union to support unions on @types/react
}) as ForwardRefComponent<ButtonProps>;
checkboxCheckedBackground = 'foo' vs ':enabled:not(:checked):not(:indeterminate)': { background: 'foo'}
Single className adding multiple css variables is much more performant than complex selectors targeting individual parts of the control
.foo {
--a: green;
--b: yellow;
}
vs
.foo:enabled:not(:checked):not(:indeterminate)' > svg
Composition approach would be difficult to adjust at runtime because css vars are already set on the root, and new styles would either need to replace the old one, be more specific, or not use makeResetStyles so user could override individual css vars
Option A: Discarded - too much additional code, circular reference problems, unwieldy for consumers. Option A2: Discarded - global function is no-go Option A3: Chosen - minmimal introduction of new types, support tree-shaking, provides full styling escape-hatch to consumers. Option B: Deferred/Parallel - recomposition is the recommended approach for many scenarios and we should continue making it easier. It is not the centralized escape-hatch solution. Option C: Deferred - component-level tokens will require longer term investigation and solution brainstorming, including CSS parts. They would make the existing Fluent 2 tokens more customizable, but would still have built-in limitations that prevent them from being a complete escape-hatch.
from Mason Tejera
Our primary customer for applying component specific styles via the ThemeProvider has been Centro. The Core mission of Centro is to allow engineering team to build experiences (UX + Backend) one time and ship them to any hosting application(s). They are a horizontal engineering team that services Commercial and Consumer experiences. They surface many of their experiences across multiple apps & platforms and require them to look native to the end user. This sort of functionality saves Centro and their consumers a LOT of engineering time. A couple specific examples of how they use it:
Centro built an embedded admin feature thatβs currently used in MetaOS app in Teams, Office, and soon Outlook. In their web instance, the paradigm is to show widget details in a panel. Teams reserves panels for more specific purposes (chats), so to get a native look, they apply a style to all their panels to appear as a modals. Doing this with the ThemeProvider styles in v8 took less than a week of dev time. There are 13 other components touched with this mechanism in the Teams Theme.
Centro supports a Feedback team that needs to feel native and branded in all Office apps on every platform, ie. Windows, macOS, iOS, Android & Teams. They currently have a dedicated Windows theme that touches the styles for 8 Fluent components. This team also ingests the above Teams theme, which saved 2 months of engineering cycles and lead time.
In total there are nearly 100 experiences that are reused across approximately 30 apps, each app has some overlap in experience but also some uniqueness beyond color. Easy and powerful theming has helped Centro convince consuming teams to opt into Fluent who otherwise would have turned to different technologies. It has made both Centro and Fluent more successful.
from Mason Tejera
This is also an extremely powerful tool for Fluent extension libraries. Both MADS & Security & Compliance use styles from the ThemeProvider to solve a diverse range of problems.
We use it to inject styling opinions within the context of a specific section of the application. Examples include changing background colors within panels, setting icon button colors to black within a nav bar.
We often use it to disagree with subtle styling opinions of specific components (ie, pivots being indented, buttons having borders, etc). Having the wrapper baked into fluent keeps our code consistent with other teams and reduces the surface area of what we need to support.
We sometimes use this to patch regressions in CSS caused at the Fluent level. Itβs faster for us to fix in place rather than go through the process of getting it fixed directly in Fluent.
We regularly use this functionality to fix color contrast ratio bugs related to a11y, especially when the brand colors are changed. There are many instances where re-mapping color slots at a central location has hard to track ripple effects. The best way to fix the issue is often assigning a new semantic slot or palette color to a specific element in the componentβs styles.
There are several mechanisms for customizing the theme and style of components in v9. Different mechanisms allow different scopes of customization: all components, components of a specific type, or individual instances of a component.
FluentProvider has a theme prop to define the CSS variable values corresponding to the Fluent 2 design tokens.
A complete theme or a partial theme can be passed to FluentProvider.
FluentProvider can be used multiple times at different levels of the DOM hierarchy to customize a subset of component instances.
FluentProvider will also accept a theme with additional name/value pairs to define extended CSS variables.
Callers can use the themeToTokensObject method to get the set of tokens to be referenced when creating styles.
themeToTokens prevents tree-shaking of tokensclassname PropAll v9 components have a className prop which applies custom styles.
Each slot also has a className props which applies custom styles to a part of a component.
Custom styles can be built with Griffel makeStyles() and mergeClasses().
Components are built using v9's hooks composition architecture.
Recomposing a component allows behavior, style, or layout to be fully customized.