Home Services Work About Blog Contact Let's Talk
Blog / SPFx Development
SPFx Development

SPFx Property Pane: Building Custom Controls for Web Part Configuration

Property Pane Basics: What You Get and What You Don't

Every SPFx web part exposes a property pane — the panel that slides in when an editor clicks the pencil icon on the web part. The SPFx framework ships with a set of standard field types: text, toggle, slider, dropdown, checkbox, choice group, link, and button. For straightforward configuration requirements these cover most needs. But the moment a business requirement asks for something like "let the user pick a SharePoint list from this site" or "choose a person from the directory" or "select a brand colour", the built-in controls fall short.

The standard PropertyPaneDropdown requires you to provide a static array of options at the time getPropertyPaneConfiguration() is called. It cannot fetch options asynchronously — you cannot call fetch inside that method and populate the options from a SharePoint REST response. The framework does support a pattern called the "reactive vs. non-reactive" property pane, and the loadPropertyPaneResources() lifecycle method for deferred loading, but neither makes async option fetching straightforward. Custom controls solve this cleanly.

A custom property pane control is a class that implements the IPropertyPaneField interface from @microsoft/sp-property-pane. The interface is simple: provide a type, a properties object, and a render() method that injects a React component (or vanilla DOM) into the property pane panel. The framework calls render() whenever the property pane opens or updates, giving your control full control over what appears and how it behaves.

The IPropertyPaneField Interface: Architecture of a Custom Control

Building a custom property pane control starts with understanding IPropertyPaneField. The interface lives in @microsoft/sp-property-pane and defines the contract every custom field must fulfil. You create two things: an internal class that implements the interface and contains the control logic, and a factory function (named conventionally as PropertyPaneYourControl) that web parts call inside getPropertyPaneConfiguration() — exactly like the built-in controls.

TypeScript — IPropertyPaneField skeleton for a custom control
import {
  IPropertyPaneField,
  PropertyPaneFieldType,
} from '@microsoft/sp-property-pane';
import * as React from 'react';
import * as ReactDOM from 'react-dom';

export interface ISharePointListPickerProps {
  label: string;
  spHttpClient: SPHttpClient;
  siteUrl: string;
  selectedList: string;
  onChanged: (value: string) => void;
}

class PropertyPaneSharePointListPickerBuilder
  implements IPropertyPaneField<ISharePointListPickerProps> {
  public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
  public properties: ISharePointListPickerProps;
  private _targetProperty: string;
  private _onChanged: (value: string) => void;

  public constructor(targetProperty: string, props: ISharePointListPickerProps) {
    this._targetProperty = targetProperty;
    this.properties = props;
    this._onChanged = props.onChanged;
  }

  public render(elem: HTMLElement): void {
    ReactDOM.render(
      React.createElement(SharePointListPickerControl, {
        ...this.properties,
        onChanged: this._onChanged,
      }),
      elem
    );
  }

  public dispose(elem: HTMLElement): void {
    ReactDOM.unmountComponentAtNode(elem);
  }
}

export function PropertyPaneSharePointListPicker(
  targetProperty: string,
  props: ISharePointListPickerProps
): IPropertyPaneField<ISharePointListPickerProps> {
  return new PropertyPaneSharePointListPickerBuilder(targetProperty, props);
}

The render() method receives the DOM element that the property pane allocates for your control. You can render anything into it — we use ReactDOM.render() so our controls share the same component model as the main web part. The dispose() method is called when the property pane closes; always unmount your React tree here to avoid memory leaks. These two methods are the minimum required by the interface.

Dynamic Dropdown: Fetching SharePoint Lists at Runtime

The most frequently requested custom control in SharePoint projects is a dropdown that populates with the current site's lists at runtime. This solves a fundamental limitation of the standard PropertyPaneDropdown, which requires a static options array. The control needs to: fetch lists from the SharePoint REST API when it mounts, show a loading spinner while fetching, display the list titles as dropdown options, and call the onChanged callback when the user selects a list.

TypeScript — SharePointListPicker React component (the actual control UI)
const SharePointListPickerControl: React.FC<ISharePointListPickerProps> = ({
  label, spHttpClient, siteUrl, selectedList, onChanged
}) => {
  const [lists, setLists] = useState<IDropdownOption[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    spHttpClient
      .get(
        `${siteUrl}/_api/web/lists?$select=Id,Title&$filter=Hidden eq false and BaseTemplate eq 100&$orderby=Title`,
        SPHttpClient.configurations.v1
      )
      .then(r => r.json())
      .then(data => {
        if (!cancelled) {
          setLists(data.value.map((l: any) => ({ key: l.Title, text: l.Title })));
          setLoading(false);
        }
      });
    return () => { cancelled = true; };
  }, [spHttpClient, siteUrl]);

  if (loading) return <Spinner size={SpinnerSize.small} label={`Loading ${label}...`} />;

  return (
    <Dropdown
      label={label}
      options={lists}
      selectedKey={selectedList}
      onChange={(_, opt) => opt && onChanged(opt.key as string)}
      placeholder="Select a list"
    />
  );
};

In the web part class, register the control in getPropertyPaneConfiguration() and pass the framework context and a callback that calls this.onPropertyPaneFieldChanged(). The this.context.spHttpClient and this.context.pageContext.web.absoluteUrl values provide everything the control needs to fetch lists from the correct site. When the user selects a list, the callback saves the value to this.properties.listName and triggers a web part re-render.

People Picker Control: Selecting Users from the Directory

A people picker in the property pane lets authors configure web parts that display content specific to a person — a profile card, a task list filtered by owner, or a department head spotlight. The @pnp/spfx-controls-react package (PnP SPFx React Controls) ships a battle-tested PeoplePicker component. Wrapping it in a custom property pane field takes about 30 lines of code and gives you a production-quality control that handles search-as-you-type, presence indicators, and persona cards.

TypeScript — People picker wrapper using PnP SPFx React Controls
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';

const PropertyPanePeoplePickerControl: React.FC<{
  context: WebPartContext;
  label: string;
  selectedUsers: string[];
  onChanged: (users: string[]) => void;
}> = ({ context, label, selectedUsers, onChanged }) => (
  <PeoplePicker
    context={context as any}
    titleText={label}
    personSelectionLimit={1}
    showtooltip={false}
    required={false}
    defaultSelectedUsers={selectedUsers}
    onChange={(items) => {
      onChanged(items.map((i: any) => i.secondaryText)); // secondaryText = email
    }}
    principalTypes={[PrincipalType.User]}
    resolveDelay={500}
  />
);
Tip

Store the selected user's email (the secondaryText from the persona item) rather than their display name in this.properties. Display names change when people marry or change roles — email addresses are the stable identifier in Microsoft 365.

Colour Picker Control: Brand-Consistent Theming

Colour pickers in the property pane let web part authors choose accent colours, background colours, or text colours without touching code. Fluent UI ships a ColorPicker component but it is not designed for the narrow property pane column — it renders a full 300px wide colour wheel that overflows on smaller screens. A practical alternative is a compact swatch palette rendered as a row of colour circles, with an optional hex input for custom colours.

TypeScript — Compact swatch colour picker for the property pane
const BRAND_SWATCHES = [
  '#7C3AED', '#5B21B6', '#0369A1', '#065F46',
  '#D95858', '#B45309', '#1B2A4A', '#374151',
];

const ColourPickerControl: React.FC<{
  label: string;
  selectedColour: string;
  onChanged: (colour: string) => void;
}> = ({ label, selectedColour, onChanged }) => (
  <div>
    <Label>{label}</Label>
    <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
      {BRAND_SWATCHES.map(colour => (
        <div
          key={colour}
          onClick={() => onChanged(colour)}
          style={{
            width: '28px', height: '28px', borderRadius: '50%',
            background: colour, cursor: 'pointer',
            border: selectedColour === colour ? '3px solid #1B2A4A' : '2px solid transparent',
            boxShadow: selectedColour === colour ? '0 0 0 2px white inset' : 'none',
          }}
          title={colour}
        />
      ))}
    </div>
    <TextField
      placeholder="Custom hex (e.g. #FF5722)"
      value={selectedColour}
      onChange={(_, v) => v && /^#[0-9A-Fa-f]{6}$/.test(v) && onChanged(v)}
      style={{ marginTop: '8px' }}
    />
  </div>
);

Async Loading: The loadPropertyPaneResources Lifecycle Method

SPFx provides a dedicated lifecycle method, loadPropertyPaneResources(), specifically for loading data before the property pane opens. Override it in your web part class to perform async operations — fetching list names, loading user data, retrieving configuration from a SharePoint list — before getPropertyPaneConfiguration() is called. The framework awaits the promise returned by loadPropertyPaneResources() before rendering the property pane, so you can safely use the fetched data in your control's properties.

TypeScript — loadPropertyPaneResources for pre-fetching list options
private _listOptions: IDropdownOption[] = [];

protected async loadPropertyPaneResources(): Promise<void> {
  const endpoint = `${this.context.pageContext.web.absoluteUrl}/_api/web/lists?$select=Title&$filter=Hidden eq false and BaseTemplate eq 100&$orderby=Title`;
  const response = await this.context.spHttpClient.get(
    endpoint,
    SPHttpClient.configurations.v1
  );
  if (response.ok) {
    const data = await response.json();
    this._listOptions = data.value.map((l: any) => ({ key: l.Title, text: l.Title }));
  }
}

protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
  return {
    pages: [{
      groups: [{
        groupFields: [
          // Standard dropdown can now use the pre-fetched options
          PropertyPaneDropdown('listName', {
            label: 'Select List',
            options: this._listOptions,
          }),
        ],
      }],
    }],
  };
}
Note

loadPropertyPaneResources() is called once per property pane open, not once per web part lifecycle. This means the fetch runs every time the user opens the property pane — cache the result in a class-level variable if fetching is expensive.

Packaging Controls as Reusable npm Packages

Once you have built a set of custom property pane controls that work across multiple web parts in a single project, the natural next step is making them available to all SPFx solutions in your organisation. The approach is to extract the controls into a separate npm package, publish it to your organisation's private npm registry (Azure Artifacts or GitHub Packages), and install it in any SPFx project that needs it — exactly how the community uses @pnp/spfx-property-controls.

Structure your reusable controls package with a src/controls/ folder, one subfolder per control, and a top-level index.ts that re-exports all factory functions. Use @microsoft/sp-property-pane and @microsoft/sp-http as peer dependencies (not direct dependencies) so the host web part's versions are used, avoiding bundle duplication. Ship TypeScript declaration files alongside the JavaScript output so consuming web parts get full IntelliSense.

JSON — package.json structure for a reusable SPFx controls library
{
  "name": "@contoso/spfx-property-controls",
  "version": "1.0.0",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "peerDependencies": {
    "@microsoft/sp-http": "^1.18.0",
    "@microsoft/sp-property-pane": "^1.18.0",
    "react": "^17.0.0",
    "react-dom": "^17.0.0"
  },
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  }
}

Troubleshooting: Common Property Pane Control Failures

Custom property pane controls have a handful of failure modes that are not obvious from the error messages. Understanding them saves hours of debugging when a control appears blank, fails silently, or causes the property pane to stop opening.

  • Blank control panel: The most common cause is an unhandled promise rejection in your async fetch inside the control's React component. Wrap all fetch calls in try/catch and render an error message state — the property pane swallows unhandled errors and simply shows nothing.
  • Property pane not re-opening after changes: If you call this.context.propertyPane.refresh() inside a control's onChanged callback while the pane is already open, you can create an infinite refresh loop on certain SPFx versions. Call this.onPropertyPaneFieldChanged() instead and let the framework manage pane refresh.
  • SPHttpClient is undefined in the control: The framework passes your properties object by reference to the control's render() method — but if you update the properties object after the pane is open (for example, in onPropertyPaneFieldChanged), the control's React component does not automatically receive new props. You must unmount and remount via dispose() and render(), or manage props through a mutable ref.
  • Styles bleeding into the property pane: The property pane renders in a different DOM subtree from the main web part. CSS Modules styles from your web part will not apply to the property pane control. Import Fluent UI styles directly, or add a scoped style element inside your control's React component using a useEffect that appends a <style> tag.
Watch Out

Always implement the dispose() method and call ReactDOM.unmountComponentAtNode(elem). Forgetting this causes React component trees to accumulate in memory every time the property pane opens and closes, producing increasingly sluggish page behaviour over long editing sessions.

Key Takeaways

A custom property pane control implements IPropertyPaneField with a render() method that mounts a React component and a dispose() method that unmounts it — the minimal contract the framework requires.

Use loadPropertyPaneResources() to pre-fetch async data (like list names) before the property pane opens — this is the cleanest approach for populating standard dropdowns without building a fully custom control.

Wrap PnP SPFx React Controls (PeoplePicker, DateTimePicker, etc.) in the IPropertyPaneField adapter pattern to get production-quality controls with minimal implementation effort.

Always wrap fetch calls inside custom control React components in try/catch — unhandled rejections cause the property pane to render a blank control with no visible error.

Package reusable controls as a private npm library with @microsoft/sp-property-pane as a peer dependency — this eliminates bundle duplication and ensures version compatibility across all consuming SPFx projects.

AT

Akshara Technologies

Microsoft 365 Development Specialists

With 10+ years building enterprise SharePoint, SPFx, Power Automate, and Flutter solutions for clients across India, USA, UAE, and Australia — we write from production experience, not documentation.

Related Articles

Need Custom SPFx Controls Built?

From property pane controls to full web part suites — Akshara Technologies delivers production-ready SPFx solutions for Microsoft 365 enterprises.

Start Your Project View Case Studies