- Published on
SPFx Property Pane — Advanced Custom Controls
The built-in property pane controls — text field, dropdown, toggle, slider — cover basic configuration needs. But the moment you need a dropdown that loads its options from a SharePoint list, a colour picker that matches your brand palette, or a range slider with custom step increments, you are writing a custom property pane control.
Custom property pane controls are one of the most frequently requested SPFx patterns in the community, and one of the most under-documented. This article builds three production-ready controls — async dropdown, colour picker, and slider — and shows you how to package them for reuse across web parts.
🗺️ How Custom Property Pane Controls Work
A custom property pane control is a class that implements IPropertyPaneField<TProperties>. It has two responsibilities:
- Render — return a React element via
render(elem, onChanged)that mounts into the property pane DOM - Notify — call
onChangedwith the new value when the user makes a selection
The web part receives the new value via onPropertyPaneFieldChanged and the property bag is updated automatically.
The base class to extend is PropertyPaneField<TProperties>:
import {
IPropertyPaneField,
PropertyPaneFieldType
} from '@microsoft/sp-property-pane';
export abstract class BaseCustomField<TProperties>
implements IPropertyPaneField<TProperties> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: TProperties;
public shouldFocus?: boolean;
constructor(targetProperty: string, properties: TProperties) {
this.targetProperty = targetProperty;
this.properties = properties;
}
public abstract render(
elem: HTMLElement,
onChanged?: (targetProperty: string, newValue: unknown) => void,
onRender?: (elem: HTMLElement) => void,
disableReactivePropertyChanges?: boolean
): void;
public onDispose(elem: HTMLElement): void {
ReactDOM.unmountComponentAtNode(elem);
}
}
🧩 Control 1 — Async Dropdown (loads from SharePoint)
The most commonly requested custom control: a dropdown that fetches its options from a SharePoint list at render time.
src/propertyPane/AsyncDropdown.ts
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { SPFI } from '@pnp/sp';
import { BaseCustomField } from './BaseCustomField';
import AsyncDropdownComponent from './components/AsyncDropdownComponent';
export interface IAsyncDropdownProperties {
label: string;
sp: SPFI;
listName: string;
selectedKey: string;
onChanged: (value: string) => void;
}
export class AsyncDropdownField extends BaseCustomField<IAsyncDropdownProperties> {
public render(
elem: HTMLElement,
onChanged?: (targetProperty: string, newValue: unknown) => void
): void {
ReactDOM.render(
React.createElement(AsyncDropdownComponent, {
...this.properties,
onChanged: (value: string) => {
this.properties.onChanged(value);
if (onChanged) onChanged(this.targetProperty, value);
}
}),
elem
);
}
}
src/propertyPane/components/AsyncDropdownComponent.tsx
import * as React from 'react';
import { Field, Dropdown, Option, Spinner } from '@fluentui/react-components';
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
interface IAsyncDropdownProps {
label: string;
sp: SPFI;
listName: string;
selectedKey: string;
onChanged: (value: string) => void;
}
const AsyncDropdownComponent: React.FC<IAsyncDropdownProps> = ({
label, sp, listName, selectedKey, onChanged
}) => {
const [options, setOptions] = React.useState<{ key: string; text: string }[]>([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
sp.web.lists
.getByTitle(listName)
.items
.select('Id', 'Title')
.orderBy('Title', true)
.top(100)()
.then(items => {
setOptions(items.map(i => ({ key: i.Id.toString(), text: i.Title })));
setLoading(false);
})
.catch(() => setLoading(false));
}, [listName]);
if (loading) return <Spinner size="tiny" label={`Loading ${label}...`} />;
return (
<Field label={label}>
<Dropdown
value={options.find(o => o.key === selectedKey)?.text ?? ''}
onOptionSelect={(_, d) => onChanged(d.optionValue ?? '')}
>
{options.map(opt => (
<Option key={opt.key} value={opt.key}>{opt.text}</Option>
))}
</Dropdown>
</Field>
);
};
export default AsyncDropdownComponent;
Using it in a web part property pane:
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [{
groups: [{
groupFields: [
new AsyncDropdownField('selectedListItemId', {
label: 'Select a Project',
sp: this._sp,
listName: 'Projects',
selectedKey: this.properties.selectedListItemId ?? '',
onChanged: (value) => {
this.properties.selectedListItemId = value;
}
})
]
}]
}]
};
}
🧩 Control 2 — Colour Picker
A simple colour swatch picker for selecting from a predefined brand palette:
src/propertyPane/components/ColorPickerComponent.tsx
import * as React from 'react';
import { makeStyles, tokens } from '@fluentui/react-components';
const COLORS = [
{ name: 'Brand Blue', value: '#0078D4' },
{ name: 'Green', value: '#107C10' },
{ name: 'Red', value: '#D13438' },
{ name: 'Orange', value: '#D83B01' },
{ name: 'Purple', value: '#5C2D91' },
{ name: 'Teal', value: '#008272' },
{ name: 'Dark', value: '#252423' },
{ name: 'Light Grey', value: '#E1DFDD' }
];
const useStyles = makeStyles({
label: { fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, marginBottom: '8px' },
swatches: { display: 'flex', flexWrap: 'wrap', gap: '6px' },
swatch: {
width: '28px', height: '28px', borderRadius: '4px',
cursor: 'pointer', border: '2px solid transparent',
transition: 'transform 0.1s',
'&:hover': { transform: 'scale(1.15)' }
}
});
interface IColorPickerProps {
label: string;
selectedColor: string;
onChanged: (color: string) => void;
}
const ColorPickerComponent: React.FC<IColorPickerProps> = ({ label, selectedColor, onChanged }) => {
const styles = useStyles();
return (
<div>
<div className={styles.label}>{label}</div>
<div className={styles.swatches}>
{COLORS.map(c => (
<div
key={c.value}
className={styles.swatch}
title={c.name}
style={{
background: c.value,
borderColor: c.value === selectedColor ? '#323130' : 'transparent',
boxShadow: c.value === selectedColor ? `0 0 0 2px white, 0 0 0 4px ${c.value}` : 'none'
}}
onClick={() => onChanged(c.value)}
/>
))}
</div>
</div>
);
};
export default ColorPickerComponent;
🧩 Control 3 — Range Slider with Value Display
A slider control that shows its current numeric value inline — more useful than the built-in PropertyPaneSlider for cases where you need a custom step, min/max label, or suffix:
src/propertyPane/components/SliderComponent.tsx
import * as React from 'react';
import { Slider, Field, makeStyles, tokens } from '@fluentui/react-components';
const useStyles = makeStyles({
row: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalS },
value: {
minWidth: '40px', textAlign: 'right',
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground1,
fontWeight: tokens.fontWeightSemibold
}
});
interface ISliderProps {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChanged: (value: number) => void;
}
const SliderComponent: React.FC<ISliderProps> = ({
label, value, min, max, step, suffix = '', onChanged
}) => {
const styles = useStyles();
return (
<Field label={label}>
<div className={styles.row}>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(_, data) => onChanged(data.value)}
style={{ flex: 1 }}
/>
<span className={styles.value}>{value}{suffix}</span>
</div>
</Field>
);
};
export default SliderComponent;
📦 Packaging as a Reusable Library
To share these controls across multiple SPFx projects without copy-pasting, publish them as an npm package:
package.json for the controls library:
{
"name": "@contoso/spfx-property-pane-controls",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"@microsoft/sp-property-pane": ">=1.18.0",
"@fluentui/react-components": ">=9.0.0",
"@pnp/sp": ">=3.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
src/index.ts — the library entry point:
export { AsyncDropdownField } from './AsyncDropdown';
export type { IAsyncDropdownProperties } from './AsyncDropdown';
export { ColorPickerField } from './ColorPicker';
export type { IColorPickerProperties } from './ColorPicker';
export { SliderField } from './Slider';
export type { ISliderProperties } from './Slider';
Install in any SPFx project:
npm install @contoso/spfx-property-pane-controls --save
Then use in any web part's getPropertyPaneConfiguration:
import { AsyncDropdownField, ColorPickerField, SliderField }
from '@contoso/spfx-property-pane-controls';
🚀 Deploy
npm run build
npm run bundle -- --ship
npm run package-solution -- --ship
📂 GitHub Source
View full SPFx project on GitHub:SPFx custom property pane controls — async dropdown, color picker, slider (reusable library)
✅ Summary
- Custom property pane controls implement
IPropertyPaneField<TProperties>— render a React component intoelemand callonChangedwhen the value updates. - The
onDisposemethod must unmount the React tree — missing this leaks memory when the property pane is closed. - Async dropdowns fetch options in
useEffectwith a loading state — SharePoint data is available because the property pane renders in the hosted workbench context. - Package controls as a separate npm package with
peerDependencieson SPFx, Fluent UI, and React — this avoids bundling duplicates when consumed in multiple web parts. - The PnP SPFx Controls library (
@pnp/spfx-controls-react) is the community standard for reusable controls — check it before building from scratch.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
