|
| 1 | +import { Meta } from '@storybook/addon-docs/blocks'; |
| 2 | + |
| 3 | +<Meta title="Concepts/Developer/Building Custom Controls" /> |
| 4 | + |
| 5 | +## Building custom controls |
| 6 | + |
| 7 | +A custom control is a component you own entirely — its markup, its styles, its design tokens — that still needs to behave like its Fluent counterpart: correct ARIA roles, keyboard navigation, focus management, and slot structure. |
| 8 | + |
| 9 | +Building one from scratch means reimplementing behavior that Fluent UI has already solved. Base state hooks let you avoid that. They expose the behavior layer of a Fluent component as a standalone API you can call directly, while leaving rendering and styling entirely up to you. |
| 10 | + |
| 11 | +### When to use base state hooks |
| 12 | + |
| 13 | +There are three levels of customization available, depending on how much control you need: |
| 14 | + |
| 15 | +**Styled components** (`Button`, `Input`, etc.) — the default for Fluent UI v9 apps. Use when you want built-in accessibility, design tokens, and standard visual behavior with optional style overrides. |
| 16 | + |
| 17 | +**Composition hooks** (`useButton_unstable`, `useInput_unstable`, etc.) — the right choice for Fluent DS extensions. Use when you need to own rendering but still want Fluent's visual design language as a starting point. |
| 18 | + |
| 19 | +**Base state hooks** (`useButtonBase_unstable`, `useInputBase_unstable`, etc.) — use when you need: |
| 20 | + |
| 21 | +- Full control over rendering and styling architecture |
| 22 | +- A custom design system with completely different visual patterns |
| 23 | +- Fluent behavior and accessibility primitives without any styling opinions |
| 24 | + |
| 25 | +Base state hooks are not a replacement for composition hooks in most cases. If you are customizing a Fluent component's appearance or adding design variants, composition hooks are the right starting point. |
| 26 | + |
| 27 | +### What base state hooks include |
| 28 | + |
| 29 | +- Component behavior and state logic |
| 30 | +- ARIA attributes and keyboard interaction patterns |
| 31 | +- Semantic slot structure (which slots exist and their expected element types) |
| 32 | + |
| 33 | +### What base state hooks exclude |
| 34 | + |
| 35 | +- Design props (for example `appearance`, `size`, `shape`) |
| 36 | +- Style logic (Griffel styles, design token styling) |
| 37 | +- Motion and transitions |
| 38 | +- Default slot content |
| 39 | + |
| 40 | +### Layer model |
| 41 | + |
| 42 | +Components are composed in three layers. Each layer builds on the one below it: |
| 43 | + |
| 44 | +1. `use{Component}Base_unstable` — behavior, state, and semantic structure. No styling. |
| 45 | +2. `use{Component}_unstable` — applies design-level concerns (appearance, size) on top of base state. |
| 46 | +3. `{Component}` — the default styled experience, combining state, styles, and rendering. |
| 47 | + |
| 48 | +When building a custom control, you consume layer 1 directly and replace layers 2 and 3 with your own state hook, styles hook, and render function. |
| 49 | + |
| 50 | +```tsx |
| 51 | +// Layer 1: consume base behavior directly |
| 52 | +import { useButtonBase_unstable } from '@fluentui/react-button'; |
| 53 | +import type { ButtonBaseProps, ButtonBaseState } from '@fluentui/react-button'; |
| 54 | + |
| 55 | +// Add your custom props on top of the base props type |
| 56 | +type MyButtonProps = ButtonBaseProps & { |
| 57 | + variant?: 'primary' | 'secondary'; |
| 58 | +}; |
| 59 | + |
| 60 | +// Your state hook wraps useButtonBase_unstable and extends the returned state |
| 61 | +const useMyButtonState = (props: MyButtonProps, ref: React.Ref<HTMLButtonElement>) => { |
| 62 | + const { variant, ...baseProps } = props; |
| 63 | + const baseState = useButtonBase_unstable(baseProps, ref); |
| 64 | + return { ...baseState, variant }; |
| 65 | +}; |
| 66 | +``` |
| 67 | + |
| 68 | +The full example in the next section shows layers 2 and 3 (styles and rendering) as well. |
| 69 | + |
| 70 | +### Authoring cookbook |
| 71 | + |
| 72 | +The example below shows the complete pattern for building a custom component on top of a base state hook. It uses a loading-aware button that replaces icon and content rendering while loading. |
| 73 | + |
| 74 | +#### Full example: custom state, styles, and conditional slot rendering |
| 75 | + |
| 76 | +```tsx |
| 77 | +import * as React from 'react'; |
| 78 | +import { assertSlots, mergeClasses, slot, type Slot } from '@fluentui/react-components'; |
| 79 | +import { useButtonBase_unstable } from '@fluentui/react-button'; |
| 80 | +import type { ButtonBaseProps, ButtonBaseState } from '@fluentui/react-button'; |
| 81 | + |
| 82 | +// --- Types --- |
| 83 | +// Extend base slot and state types with your custom additions. |
| 84 | + |
| 85 | +type LoadingButtonSlots = { |
| 86 | + root: NonNullable<ButtonBaseState['root']>; |
| 87 | + icon?: ButtonBaseState['icon']; |
| 88 | + loadingIndicator?: Slot<'span'>; |
| 89 | +}; |
| 90 | + |
| 91 | +type LoadingButtonProps = ButtonBaseProps & { |
| 92 | + isLoading?: boolean; |
| 93 | + loadingIndicator?: Slot<'span'>; |
| 94 | +}; |
| 95 | + |
| 96 | +type LoadingButtonState = ButtonBaseState & { |
| 97 | + isLoading: boolean; |
| 98 | + loadingIndicator?: ReturnType<typeof slot.optional>; |
| 99 | + components: LoadingButtonSlots; |
| 100 | +}; |
| 101 | + |
| 102 | +// --- State hook --- |
| 103 | +// Delegate ARIA, keyboard, and semantic behavior to the base hook. |
| 104 | +// Add your component-specific state on top. |
| 105 | + |
| 106 | +const useLoadingButtonState = ( |
| 107 | + props: LoadingButtonProps, |
| 108 | + ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>, |
| 109 | +): LoadingButtonState => { |
| 110 | + const { isLoading = false, loadingIndicator, ...baseProps } = props; |
| 111 | + const baseState = useButtonBase_unstable(baseProps, ref); |
| 112 | + |
| 113 | + return { |
| 114 | + ...baseState, |
| 115 | + isLoading, |
| 116 | + // slot.optional resolves the slot prop: uses the caller's value if provided, |
| 117 | + // otherwise renders the defaultProps content. Returns undefined if renderByDefault is false. |
| 118 | + loadingIndicator: slot.optional(loadingIndicator, { |
| 119 | + renderByDefault: true, |
| 120 | + defaultProps: { |
| 121 | + children: 'Loading...', |
| 122 | + 'aria-live': 'polite', |
| 123 | + }, |
| 124 | + elementType: 'span', |
| 125 | + }), |
| 126 | + components: { |
| 127 | + ...baseState.components, |
| 128 | + loadingIndicator: 'span', |
| 129 | + }, |
| 130 | + }; |
| 131 | +}; |
| 132 | + |
| 133 | +// --- Styles hook --- |
| 134 | +// Apply class names using mergeClasses. Always append the existing slot className last |
| 135 | +// so that consumer class names take precedence over your internal ones. |
| 136 | + |
| 137 | +const useLoadingButtonStyles = (state: LoadingButtonState): void => { |
| 138 | + state.root.className = mergeClasses( |
| 139 | + 'loadingButton', |
| 140 | + state.isLoading && 'loadingButton--busy', |
| 141 | + state.root.className, // consumer class names win |
| 142 | + ); |
| 143 | + |
| 144 | + if (state.loadingIndicator) { |
| 145 | + state.loadingIndicator.className = mergeClasses('loadingButton__indicator', state.loadingIndicator.className); |
| 146 | + } |
| 147 | +}; |
| 148 | + |
| 149 | +// --- Render function --- |
| 150 | +// Controls what gets rendered and when. Use assertSlots to get type-safe slot |
| 151 | +// components (state.root, state.icon, etc.) and render them as JSX elements. |
| 152 | + |
| 153 | +const renderLoadingButton = (state: LoadingButtonState) => { |
| 154 | + assertSlots<LoadingButtonSlots>(state); |
| 155 | + |
| 156 | + return ( |
| 157 | + <state.root> |
| 158 | + {state.isLoading ? ( |
| 159 | + state.loadingIndicator && <state.loadingIndicator /> |
| 160 | + ) : ( |
| 161 | + <> |
| 162 | + {state.icon && <state.icon />} |
| 163 | + {state.root.children} |
| 164 | + </> |
| 165 | + )} |
| 166 | + </state.root> |
| 167 | + ); |
| 168 | +}; |
| 169 | + |
| 170 | +// --- Component --- |
| 171 | + |
| 172 | +export const LoadingButton = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, LoadingButtonProps>( |
| 173 | + (props, ref) => { |
| 174 | + const state = useLoadingButtonState(props, ref); |
| 175 | + useLoadingButtonStyles(state); |
| 176 | + return renderLoadingButton(state); |
| 177 | + }, |
| 178 | +); |
| 179 | +``` |
| 180 | + |
| 181 | +### Authoring checklist |
| 182 | + |
| 183 | +- Preserve base root props and ref wiring — always pass the ref to `use{Component}Base_unstable`, never attach it yourself. |
| 184 | +- Keep user `className` precedence by passing existing slot class names as the last argument to `mergeClasses`. |
| 185 | +- After any rendering changes, verify keyboard behavior, ARIA semantics, and focus handling still work as expected. |
| 186 | +- Validate all visual states (`:hover`, `:active`, `:focus-visible`, disabled). |
| 187 | +- Test with a screen reader after custom rendering changes. |
| 188 | +- Keep the state, styles, and render logic in separate functions (`useCustomButtonState`, `useCustomButtonStyles`, `renderCustomButton`) for maintainability. |
| 189 | + |
| 190 | +### Accessibility responsibilities |
| 191 | + |
| 192 | +Base state hooks provide ARIA attributes and interaction patterns, but they do not enforce visual accessibility. |
| 193 | + |
| 194 | +When implementing custom styles, ensure: |
| 195 | + |
| 196 | +- Visible focus indicators on all interactive elements |
| 197 | +- Sufficient color contrast ratios |
| 198 | +- Distinct hover, pressed, and disabled visual states |
| 199 | + |
| 200 | +For detailed accessibility guidance, see [Accessibility](?path=/docs/concepts-developer-accessibility-components-overview--docs). |
| 201 | + |
| 202 | +### Relationship to other customization options |
| 203 | + |
| 204 | +- Use [Styling Components](?path=/docs/concepts-developer-styling-components--docs) for standard style overrides. |
| 205 | +- Use [Advanced Styling Techniques](?path=/docs/concepts-developer-advanced-styling-techniques--docs) for app-wide style hook customization. |
| 206 | +- Use [Customizing Components with Slots](?path=/docs/concepts-developer-customizing-components-with-slots--docs) for part-level composition within existing components. |
| 207 | + |
| 208 | +For deeper rationale and design decisions, see the RFC: [Component Base State Hooks](https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/react-components/convergence/base-state-hooks.md). |
0 commit comments