logo
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:

  1. Render — return a React element via render(elem, onChanged) that mounts into the property pane DOM
  2. Notify — call onChanged with 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)

GitHub

✅ Summary

  • Custom property pane controls implement IPropertyPaneField<TProperties> — render a React component into elem and call onChanged when the value updates.
  • The onDispose method must unmount the React tree — missing this leaks memory when the property pane is closed.
  • Async dropdowns fetch options in useEffect with 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 peerDependencies on 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!

Ad image