Live training: next batches coming soon. Join the waitlist:Waitlists open:Developer Training →Developer →Copilot Studio Training →Copilot Studio →

How to Build a SharePoint Accordion Web Part with SPFx and the PnP Accordion Control

SharePoint Online doesn’t ship an accordion web part, so FAQ pages turn into long walls of text. The fix I use is a small SPFx web part built on the free Accordion control from @pnp/spfx-controls-react:

import { Accordion } from '@pnp/spfx-controls-react/lib/Accordion';

<Accordion title="How do I reset my Microsoft 365 password?" defaultCollapsed={true}
  collapsedIcon="ChevronRightMed" expandedIcon="ChevronDownMed">
  <p>Go to https://aka.ms/sspr, verify your identity and choose a new password.</p>
</Accordion>

Each Accordion renders one clickable question; its children show only while it’s expanded.

In this tutorial, we’ll build a SharePoint accordion web part that reads questions and answers from a SharePoint list, adds a list title and an “expand the first item” option to the property pane, and also shows a static version with hard-coded items.

Tested withVersion
SharePoint Framework1.23.2 (Heft toolchain)
Node.js22 LTS (v22.22.2)
React17.0.1
PnPjs / PnP SPFx controls@pnp/sp 4.22.0 / @pnp/spfx-controls-react 3.25.0

Does SharePoint have a built-in accordion web part?

Not as a web part, but modern pages have a no-code option: collapsible sections. Edit the page, select a section, choose Edit Properties, and turn on Make this section collapsible.

You can then set the section title, the expand/collapse icon alignment and whether it starts expanded or collapsed under Default display. For a handful of static sections, that’s often enough.

Build a custom web part when the content lives in a list, editors shouldn’t touch the page layout, or you want the same FAQ on many sites. If SPFx is new to you, start with the SharePoint Framework tutorials.

Older posts, including one of mine, loaded jQuery UI’s accordion() inside an SPFx web part. I don’t recommend it anymore: it adds jQuery and its CSS to the page, it fights React for the DOM, and the PnP control does the same job natively.

PnP Accordion control properties

The control comes from the PnP reusable controls package, the same one that gives you the PnP pagination control and the ListView. These are its props in version 3.25.0:

PropertyTypeRequiredWhat it does
titlestringYesThe clickable header text (your question)
defaultCollapsedbooleanNoStart collapsed; the default is false (expanded)
classNamestringNoExtra CSS class for styling
collapsedIconstringNoFluent UI icon name shown while collapsed
expandedIconstringNoFluent UI icon name shown while expanded

Two things I found in the 3.25.0 source matter in practice. The control reads defaultCollapsed only once, when it mounts. And the fallback icon names are misspelled, so always pass collapsedIcon and expandedIcon yourself.

Build the SharePoint accordion web part step by step

You need Node.js 22 LTS, Yeoman, the SharePoint generator and Heft installed globally. My SPFx development environment setup with Heft covers that in detail; the short version is one command:

npm install @rushstack/heft yo @microsoft/generator-sharepoint --global

Step 1: Create the FAQ list

The web part reads a list called SPGDemo FAQs with four columns: Title (renamed Question), Answer (multiple lines of text), Category (choice) and SortOrder (number). You can create it in the browser or create the list and columns with PnP PowerShell:

Connect-PnPOnline -Url "https://spguides.sharepoint.com/sites/SPGuides" -ClientId "<your-app-id>" -Interactive

New-PnPList -Title "SPGDemo FAQs" -Template GenericList -OnQuickLaunch
Set-PnPField -List "SPGDemo FAQs" -Identity "Title" -Values @{ Title = "Question" }
Add-PnPField -List "SPGDemo FAQs" -DisplayName "Answer" -InternalName "Answer" -Type Note -AddToDefaultView
Add-PnPField -List "SPGDemo FAQs" -DisplayName "Category" -InternalName "Category" -Type Choice -AddToDefaultView `
    -Choices "Accounts", "Devices", "Email", "Software"
Add-PnPField -List "SPGDemo FAQs" -DisplayName "SortOrder" -InternalName "SortOrder" -Type Number -AddToDefaultView

Add-PnPListItem -List "SPGDemo FAQs" -Values @{
    Title = "How do I reset my Microsoft 365 password?"
    Answer = "Go to https://aka.ms/sspr, verify with your phone or authenticator app, and choose a new password."
    Category = "Accounts"; SortOrder = 1 }

I added eight real help desk questions this way. Here’s the list on the SPGuides site:

SPGDemo FAQs SharePoint list with Question, Answer, Category and SortOrder columns

Step 2: Scaffold the SPFx project

Open a terminal (I use the one inside VS Code) and run the generator:

md spg-accordion
cd spg-accordion
yo @microsoft/sharepoint

Answer the prompts like this. SharePoint Online is the only environment, so the generator doesn’t ask for it:

What is your solution name? spg-accordion
Which type of client-side component to create? WebPart
What is your Web part name? SpgAccordion
Which template would you like to use? React

The generator installs packages for a few minutes. Skip npm audit fix afterward; Microsoft warns it upgrades untested packages. If it stops with EPERM or ENOENT errors, see fixing npm install errors in SPFx.

Step 3: Install PnPjs and the PnP controls

PnPjs reads the list and the controls package gives us the accordion. Pin both versions:

npm install @pnp/sp@4.22.0 @pnp/spfx-controls-react@3.25.0 --save-exact

PnPjs v4 uses selective imports, so you only bundle the parts you use. My guide on displaying SharePoint list items in an SPFx web part explains the setup in more depth.

Step 4: Add the model and the data service

Create src/webparts/spgAccordion/models/IFaqItem.ts. It describes one list item:

export interface IFaqItem {
  Id: number;
  Title: string;      // the question
  Answer: string;     // multiple lines of text (plain text)
  Category: string;   // choice column
  SortOrder: number;
}

Then create src/webparts/spgAccordion/services/FaqService.ts:

import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { IFaqItem } from '../models/IFaqItem';

export class FaqService {
  constructor(private _sp: SPFI) {}

  public async getFaqs(listTitle: string, maxItems: number): Promise<IFaqItem[]> {
    return this._sp.web.lists.getByTitle(listTitle).items
      .select('Id', 'Title', 'Answer', 'Category', 'SortOrder')
      .orderBy('SortOrder', true)
      .top(maxItems)();
  }
}

It selects only the four columns, sorts by SortOrder and returns the first maxItems rows. To add a filter, such as one category, see how to filter SharePoint list items in SPFx.

Step 5: Write the React component

Replace components/ISpgAccordionProps.ts with the props the web part passes down:

import { FaqService } from '../services/FaqService';

export interface ISpgAccordionProps {
  heading: string;
  dataSource: string;      // 'list' or 'static'
  listTitle: string;
  maxItems: number;
  expandFirst: boolean;
  service: FaqService;
}

Now replace components/SpgAccordion.tsx. This is the whole accordion:

import * as React from 'react';
import { Accordion } from '@pnp/spfx-controls-react/lib/Accordion';
import { MessageBar, MessageBarType, Spinner } from '@fluentui/react';
import styles from './SpgAccordion.module.scss';
import { ISpgAccordionProps } from './ISpgAccordionProps';
import { IFaqItem } from '../models/IFaqItem';
import StaticAccordion from './StaticAccordion';

export default function SpgAccordion(props: ISpgAccordionProps): React.ReactElement<ISpgAccordionProps> {
  const { heading, dataSource, listTitle, maxItems, expandFirst, service } = props;
  const [items, setItems] = React.useState<IFaqItem[]>([]);
  const [loading, setLoading] = React.useState<boolean>(false);
  const [error, setError] = React.useState<string>('');

  React.useEffect(() => {
    if (dataSource !== 'list' || !listTitle) { return; }
    const load = async (): Promise<void> => {
      setLoading(true);
      setError('');
      try {
        setItems(await service.getFaqs(listTitle, maxItems));
      } catch (e) {
        setError(`Couldn't read the list "${listTitle}": ${(e as Error).message}`);
      } finally {
        setLoading(false);
      }
    };
    load().catch(console.error);
  }, [dataSource, listTitle, maxItems]);

  return (
    <section className={styles.spgAccordion}>
      {heading && <h2 className={styles.heading}>{heading}</h2>}
      {dataSource === 'static' && <StaticAccordion expandFirst={expandFirst} />}
      {dataSource === 'list' && loading && <Spinner label="Loading FAQs..." />}
      {dataSource === 'list' && error && <MessageBar messageBarType={MessageBarType.error}>{error}</MessageBar>}
      {dataSource === 'list' && !loading && !error && items.length === 0 &&
        <MessageBar>{`No FAQs found in the list ${listTitle}.`}</MessageBar>}
      {dataSource === 'list' && items.map((item, index) => (
        <Accordion
          key={`${item.Id}-${expandFirst}`}
          title={item.Title}
          defaultCollapsed={!(expandFirst && index === 0)}
          className={styles.item}
          collapsedIcon="ChevronRightMed"
          expandedIcon="ChevronDownMed"
        >
          <div className={styles.answer}>
            <p>{item.Answer}</p>
            {item.Category && <span className={styles.category}>{item.Category}</span>}
          </div>
        </Accordion>
      ))}
    </section>
  );
}

The effect loads items whenever the list title or item count changes. A spinner shows while loading, and a red message bar appears if the list name is wrong.

Notice the key: it includes expandFirst. Because the control reads defaultCollapsed only on mount, a new key forces React to recreate each panel when you flip the toggle.

SpgAccordion.tsx open in VS Code with the models and services folders in Explorer

Replace components/SpgAccordion.module.scss with this. The [theme:...] tokens pick up the site theme; read more on getting the current theme in SPFx:

@import '~@fluentui/react/dist/sass/References.scss';

.spgAccordion {
  color: "[theme:bodyText, default: #323130]";

  .heading {
    font-size: 20px;
    font-weight: 600;
    margin: 0 0 12px;
  }

  .item {
    border-bottom: 1px solid "[theme:neutralLight, default: #edebe9]";

    button {
      font-size: 16px;
      font-weight: 600;
    }
  }

  .answer {
    padding: 4px 12px 12px 32px;
    line-height: 1.5;

    p {
      margin: 0 0 8px;
    }
  }

  .category {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 10px;
    font-size: 12px;
    background: "[theme:themeLighter, default: #deecf9]";
    color: "[theme:themeDarker, default: #004578]";
  }
}

Step 6: Add the property pane fields

Replace src/webparts/spgAccordion/SpgAccordionWebPart.ts. It sets up PnPjs once in onInit() and defines five property pane fields:

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
  type IPropertyPaneConfiguration,
  PropertyPaneTextField,
  PropertyPaneDropdown,
  PropertyPaneToggle,
  PropertyPaneSlider
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { spfi, SPFx } from '@pnp/sp';

import * as strings from 'SpgAccordionWebPartStrings';
import SpgAccordion from './components/SpgAccordion';
import { ISpgAccordionProps } from './components/ISpgAccordionProps';
import { FaqService } from './services/FaqService';

export interface ISpgAccordionWebPartProps {
  heading: string;
  dataSource: string;
  listTitle: string;
  maxItems: number;
  expandFirst: boolean;
}

export default class SpgAccordionWebPart extends BaseClientSideWebPart<ISpgAccordionWebPartProps> {
  private _service!: FaqService;

  protected async onInit(): Promise<void> {
    await super.onInit();
    const sp = spfi().using(SPFx(this.context));
    this._service = new FaqService(sp);
  }

  public render(): void {
    const element: React.ReactElement<ISpgAccordionProps> = React.createElement(SpgAccordion, {
      heading: this.properties.heading,
      dataSource: this.properties.dataSource,
      listTitle: this.properties.listTitle,
      maxItems: this.properties.maxItems,
      expandFirst: this.properties.expandFirst,
      service: this._service
    });
    ReactDom.render(element, this.domElement);
  }

  protected onDispose(): void {
    ReactDom.unmountComponentAtNode(this.domElement);
  }

  protected get dataVersion(): Version {
    return Version.parse('1.0');
  }

  protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [{
        header: { description: strings.PropertyPaneDescription },
        groups: [{
          groupName: strings.BasicGroupName,
          groupFields: [
            PropertyPaneTextField('heading', { label: strings.HeadingFieldLabel }),
            PropertyPaneDropdown('dataSource', {
              label: strings.DataSourceFieldLabel,
              options: [
                { key: 'list', text: 'SharePoint list' },
                { key: 'static', text: 'Static items (hard-coded)' }
              ]
            }),
            PropertyPaneTextField('listTitle', {
              label: strings.ListTitleFieldLabel,
              disabled: this.properties.dataSource !== 'list'
            }),
            PropertyPaneSlider('maxItems', { label: strings.MaxItemsFieldLabel, min: 1, max: 50, step: 1 }),
            PropertyPaneToggle('expandFirst', { label: strings.ExpandFirstFieldLabel, onText: 'Yes', offText: 'No' })
          ]
        }]
      }]
    };
  }
}

The List title box is disabled when you pick static items. For more field types, see SPFx property pane controls. Update the labels in loc/mystrings.d.ts:

declare interface ISpgAccordionWebPartStrings {
  PropertyPaneDescription: string;
  BasicGroupName: string;
  HeadingFieldLabel: string;
  DataSourceFieldLabel: string;
  ListTitleFieldLabel: string;
  MaxItemsFieldLabel: string;
  ExpandFirstFieldLabel: string;
}

declare module 'SpgAccordionWebPartStrings' {
  const strings: ISpgAccordionWebPartStrings;
  export = strings;
}

And the matching text in loc/en-us.js:

define([], function() {
  return {
    "PropertyPaneDescription": "Show FAQs from a SharePoint list as an accordion.",
    "BasicGroupName": "Accordion settings",
    "HeadingFieldLabel": "Heading",
    "DataSourceFieldLabel": "Data source",
    "ListTitleFieldLabel": "List title",
    "MaxItemsFieldLabel": "Number of items to show",
    "ExpandFirstFieldLabel": "Expand the first item"
  }
});

Finally, set friendly defaults in SpgAccordionWebPart.manifest.json, so the web part works the moment it’s dropped on a page. More on this in configuring SPFx properties and preconfiguredEntries:

"preconfiguredEntries": [{
  "groupId": "5c03119e-3074-46fd-976b-c60198311f70",
  "group": { "default": "Advanced" },
  "title": { "default": "SPG Accordion" },
  "description": { "default": "FAQ accordion from a SharePoint list (PnP Accordion control)" },
  "officeFabricIconFontName": "CollapseMenu",
  "properties": {
    "heading": "IT Help Desk FAQs",
    "dataSource": "list",
    "listTitle": "SPGDemo FAQs",
    "maxItems": 10,
    "expandFirst": true
  }
}]

Step 7: Point heft start at your site workbench

Open config/serve.json and set initialPage to the workbench of the site that has your list. Heft fills in {tenantDomain} from the SPFX_SERVE_TENANT_DOMAIN environment variable, or you can type your full tenant URL:

{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
  "port": 4321,
  "https": true,
  "initialPage": "https://{tenantDomain}/sites/SPGuides/_layouts/workbench.aspx"
}

Older projects used gulp serve; since SPFx 1.22 it’s Heft. Run this in the VS Code terminal:

heft start

Heft compiles the project, serves it from https://localhost:4321 and opens the SPGuides workbench in your default browser. It also prints the debug query string. Leave it running:

VS Code terminal running heft start with the debug query string for the accordion web part

In the workbench, select +, search for SPG Accordion and add it. Because the workbench runs inside the SPGuides site, PnPjs reads the real list:

SPG Accordion in the SharePoint workbench web part toolbox

The defaults from the manifest load the eight FAQs right away, with the first answer open:

SharePoint accordion web part in the workbench with the first FAQ expanded showing the answer and category

One caution: Microsoft deprecated the hosted workbench on 05/13/2026 and retires it on 12/01/2026. After that, open any modern page with the debug query string above and use the SharePoint Framework Debug Toolbar instead.

Set the list title and expand the first item

Open the web part’s property pane (the pencil icon). Type another list name in List title and the accordion reloads; drag the slider to show fewer items:

SPG Accordion property pane with List title SPGDemo FAQs and Expand the first item

With Expand the first item off, every question starts collapsed. That’s the compact view I use on busy home pages:

SharePoint accordion web part with all FAQ questions collapsed

Turn it back on and the first answer opens again, as in the workbench shot earlier. Readers can open more panels; each one toggles on its own.

Static accordion with hard-coded items

Sometimes the content never changes, like help desk hours. Then you don’t need a list at all. Create components/StaticAccordion.tsx:

import * as React from 'react';
import { Accordion } from '@pnp/spfx-controls-react/lib/Accordion';
import styles from './SpgAccordion.module.scss';

const items = [
  { question: 'What are the IT Service Desk hours?', answer: 'Monday to Friday, 7:00 AM to 7:00 PM Eastern Time. Urgent outages are covered 24/7 by the on-call engineer.' },
  { question: 'How do I contact the IT Service Desk?', answer: 'Call extension 4357 (HELP) or email servicedesk@spguides.com. Every request gets a ticket number.' },
  { question: 'How fast will my ticket be answered?', answer: 'High-priority tickets within 1 hour, normal tickets within 1 business day.' }
];

export default function StaticAccordion(props: { expandFirst: boolean }): React.ReactElement {
  return (
    <div>
      {items.map((item, index) => (
        <Accordion
          key={`${index}-${props.expandFirst}`}
          title={item.question}
          defaultCollapsed={!(props.expandFirst && index === 0)}
          className={styles.item}
          collapsedIcon="ChevronRightMed"
          expandedIcon="ChevronDownMed"
        >
          <p className={styles.answer}>{item.answer}</p>
        </Accordion>
      ))}
    </div>
  );
}

The web part already renders it when Data source is set to Static items. If you only ever need static content, keep this file and drop the service and PnPjs.

Static SPFx accordion with hard-coded IT Service Desk questions

Want both on one page? Add two copies of the web part, or put each in its own web part in a single SPFx solution with multiple web parts.

Common errors and fixes

My first build failed on this code, which looks fine in a browser console:

service.getFaqs(listTitle, maxItems)
  .then(setItems)
  .catch((e: Error) => setError(e.message))
  .finally(() => setLoading(false));   // TS2550 with the generated tsconfig
  • TS2550: Property ‘finally’ does not exist on type ‘Promise<void>’. The generated tsconfig targets an older library. Use an async function with try/catch/finally, as in the component above.
  • TS2564: Property ‘_service’ has no initializer. It’s assigned in onInit(), so declare it as private _service!: FaqService;.
  • A red message bar instead of FAQs. The list title in the property pane must match the list name exactly. The bar shows the error text PnPjs returned.
  • No chevron icons. Pass collapsedIcon and expandedIcon on every accordion; use the same pair everywhere, because the icon settings are shared across panels.
  • Expand the first item does nothing. defaultCollapsed is read only on mount; include the setting in the React key.
  • ‘gulp’ is not recognized. SPFx 1.22 and later use Heft; run heft start or npm run start. For older projects, see updating the SPFx version.

Package the accordion web part

Stop heft start with Ctrl+C, then build the production package:

npm run build

The script runs heft test --clean --production and then heft package-solution --production. It ends with sharepoint/solution/spg-accordion.sppkg:

npm run build output in VS Code creating spg-accordion.sppkg

That .sppkg file is ready to ship to your app catalog; Microsoft walks through the upload in Deploy your client-side web part to a SharePoint page.

Want the finished solution instead of building it? Grab it with our other ready-made projects on the Power Platform and SPFx solutions page.

Once the accordion works, these SPFx tutorials are good next builds:

Frequently Asked Questions

Is there an accordion web part in SharePoint Online?

There’s no out-of-the-box accordion web part. Modern pages offer collapsible sections (section properties > Make this section collapsible). For list-driven FAQs, build an SPFx web part with the PnP Accordion control.

How do I make a collapsible section on a SharePoint page?

Edit the page, select the section, choose Edit Properties and turn on Make this section collapsible. Then set the title, icon alignment and Default display. Microsoft explains each option in Add sections and columns on a SharePoint modern page.

Which package has the Accordion control for SPFx?

@pnp/spfx-controls-react. Install it with npm, then import { Accordion } from ‘@pnp/spfx-controls-react/lib/Accordion’. Version 3.25.0 works with SPFx 1.23.2.

Can I keep all accordion items collapsed by default?

Yes. Set defaultCollapsed={true} on each Accordion. In this web part, turn off Expand the first item in the property pane.

Should I still use the jQuery UI accordion in SPFx?

No. It adds jQuery to the bundle and manipulates the DOM outside React. The PnP Accordion control or collapsible sections give the same result with less code.