logo
Published on

SPFx Application Customizer — Header and Footer Patterns

The Application Customizer is the right tool whenever you need a UI element that appears on every page in a site — a global navigation bar, a sticky header with breadcrumb, a cookie consent banner, a tenant-wide notification strip, or a persistent footer with corporate links.
Unlike web parts, application customizers activate automatically — no editor needs to add them to a page. This article covers the two most common patterns: a sticky header with breadcrumb and a collapsible responsive footer.


📦 Scaffold the Extension

yo @microsoft/sharepoint

When prompted:

  • What type of client-side component? → Extension
  • Which type of client-side extension? → Application Customizer
  • What is your Application Customizer name?StickyHeader
  • Framework? → React

This generates the extension class, a loc folder, and a serve.json entry for the hosted workbench.


⚙️ Understanding the Two Placeholders

Application customizers can render into two named placeholders:

PlaceholderPositionNotes
PlaceholderName.TopAbove the SharePoint suite bar and page contentUse for headers, nav bars, notification banners
PlaceholderName.BottomBelow all page content, at the very bottom of the viewportUse for footers, cookie banners, help widgets

You do not need to use both. Most solutions use Top only or Bottom only.


🧩 Application Customizer Class

src/extensions/stickyHeader/StickyHeaderApplicationCustomizer.ts

import { override } from '@microsoft/decorators';
import {
  BaseApplicationCustomizer,
  PlaceholderContent,
  PlaceholderName
} from '@microsoft/sp-application-base';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { FluentProvider, webLightTheme } from '@fluentui/react-components';
import StickyHeader from './components/StickyHeader';
import SiteFooter from './components/SiteFooter';

export interface IStickyHeaderProperties {
  logoUrl: string;
  supportUrl: string;
}

export default class StickyHeaderApplicationCustomizer
  extends BaseApplicationCustomizer<IStickyHeaderProperties> {

  private _topPlaceholder: PlaceholderContent | undefined;
  private _bottomPlaceholder: PlaceholderContent | undefined;

  @override
  public onInit(): Promise<void> {
    // Re-render when the page navigates (SPA navigation in SharePoint)
    this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders);
    this._renderPlaceholders();
    return Promise.resolve();
  }

  private _renderPlaceholders(): void {
    // ── TOP PLACEHOLDER ──────────────────────────────────────────────
    if (!this._topPlaceholder) {
      this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(
        PlaceholderName.Top,
        { onDispose: this._onTopDispose }
      );
    }

    if (this._topPlaceholder?.domElement) {
      ReactDOM.render(
        React.createElement(
          FluentProvider,
          { theme: webLightTheme },
          React.createElement(StickyHeader, {
            context: this.context,
            logoUrl: this.properties.logoUrl
          })
        ),
        this._topPlaceholder.domElement
      );
    }

    // ── BOTTOM PLACEHOLDER ───────────────────────────────────────────
    if (!this._bottomPlaceholder) {
      this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent(
        PlaceholderName.Bottom,
        { onDispose: this._onBottomDispose }
      );
    }

    if (this._bottomPlaceholder?.domElement) {
      ReactDOM.render(
        React.createElement(
          FluentProvider,
          { theme: webLightTheme },
          React.createElement(SiteFooter, {
            supportUrl: this.properties.supportUrl
          })
        ),
        this._bottomPlaceholder.domElement
      );
    }
  }

  private _onTopDispose = (): void => {
    if (this._topPlaceholder?.domElement) {
      ReactDOM.unmountComponentAtNode(this._topPlaceholder.domElement);
    }
  };

  private _onBottomDispose = (): void => {
    if (this._bottomPlaceholder?.domElement) {
      ReactDOM.unmountComponentAtNode(this._bottomPlaceholder.domElement);
    }
  };
}

Key points:

  • Subscribe to changedEvent so the header re-renders after SharePoint's SPA navigation. Without this, navigating between pages clears the placeholder and the header disappears.
  • Call tryCreateContent — not createContent. If the placeholder does not exist on the current page (some SharePoint system pages suppress placeholders), tryCreateContent returns undefined safely.
  • Always unmount the React tree in the dispose callbacks to avoid memory leaks.

🧩 Sticky Header Component

src/extensions/stickyHeader/components/StickyHeader.tsx

import * as React from 'react';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { makeStyles, tokens, Text } from '@fluentui/react-components';

const useStyles = makeStyles({
  header: {
    position: 'sticky',
    top: 0,
    zIndex: 1000,
    background: tokens.colorNeutralBackground1,
    borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
    padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalM,
    boxShadow: tokens.shadow4
  },
  logo: {
    height: '32px',
    width: 'auto'
  },
  breadcrumb: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalXS,
    fontSize: tokens.fontSizeBase200,
    color: tokens.colorNeutralForeground2
  },
  siteName: {
    fontWeight: tokens.fontWeightSemibold,
    color: tokens.colorBrandForeground1
  },
  // Responsive: collapse breadcrumb on small screens
  '@media (max-width: 768px)': {
    breadcrumb: { display: 'none' }
  }
});

interface IStickyHeaderProps {
  context: ApplicationCustomizerContext;
  logoUrl: string;
}

const StickyHeader: React.FC<IStickyHeaderProps> = ({ context, logoUrl }) => {
  const styles = useStyles();
  const siteName = context.pageContext.web.title;
  const siteUrl = context.pageContext.web.absoluteUrl;
  const pageTitle = context.pageContext.listItem?.title ?? '';

  return (
    <header className={styles.header}>
      {logoUrl && (
        <a href={siteUrl}>
          <img src={logoUrl} alt="Site logo" className={styles.logo} />
        </a>
      )}

      <nav className={styles.breadcrumb} aria-label="Breadcrumb">
        <a href={siteUrl} className={styles.siteName}>{siteName}</a>
        {pageTitle && (
          <>
            <Text>/</Text>
            <Text>{pageTitle}</Text>
          </>
        )}
      </nav>
    </header>
  );
};

export default StickyHeader;

src/extensions/stickyHeader/components/SiteFooter.tsx

import * as React from 'react';
import { makeStyles, tokens, Link } from '@fluentui/react-components';
import { ChevronUpRegular, ChevronDownRegular } from '@fluentui/react-icons';

const useStyles = makeStyles({
  footer: {
    background: tokens.colorNeutralBackground3,
    borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
    padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
    fontSize: tokens.fontSizeBase100,
    color: tokens.colorNeutralForeground2
  },
  toggle: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalXS,
    cursor: 'pointer',
    marginBottom: tokens.spacingVerticalS,
    background: 'none',
    border: 'none',
    color: tokens.colorNeutralForeground2,
    fontSize: tokens.fontSizeBase100,
    padding: 0
  },
  links: {
    display: 'flex',
    gap: tokens.spacingHorizontalL,
    flexWrap: 'wrap'
  }
});

interface ISiteFooterProps {
  supportUrl: string;
}

const SiteFooter: React.FC<ISiteFooterProps> = ({ supportUrl }) => {
  const styles = useStyles();
  const [expanded, setExpanded] = React.useState(false);
  const year = new Date().getFullYear();

  return (
    <footer className={styles.footer}>
      <button
        className={styles.toggle}
        onClick={() => setExpanded(e => !e)}
        aria-expanded={expanded}
      >
        {expanded ? <ChevronDownRegular /> : <ChevronUpRegular />}
        {expanded ? 'Collapse footer' : 'Show footer links'}
      </button>

      {expanded && (
        <div className={styles.links}>
          <Link href={supportUrl} target="_blank">Support</Link>
          <Link href="/sites/intranet/SitePages/privacy.aspx">Privacy Policy</Link>
          <Link href="/sites/intranet/SitePages/accessibility.aspx">Accessibility</Link>
          <span>© {year} Contoso Corporation</span>
        </div>
      )}
    </footer>
  );
};

export default SiteFooter;

⚙️ Passing Properties via CustomAction JSON

Application customizer properties are passed via the ClientSideComponentProperties of the UserCustomAction that registers the extension. In serve.json for local testing:

{
  "serveConfigurations": {
    "default": {
      "pageUrl": "https://contoso.sharepoint.com/sites/dev/_layouts/15/workbench.aspx",
      "customActions": {
        "your-component-id-from-manifest": {
          "location": "ClientSideExtension.ApplicationCustomizer",
          "properties": {
            "logoUrl": "https://contoso.sharepoint.com/sites/dev/SiteAssets/logo.png",
            "supportUrl": "https://support.contoso.com"
          }
        }
      }
    }
  }
}

In production, the properties are set when adding the UserCustomAction via PowerShell, PnP PowerShell, or a site script.


🚀 Deploy

npm run build
npm run bundle -- --ship
npm run package-solution -- --ship

Upload the .sppkg to your App Catalog and deploy it. Add the custom action to a site using PnP PowerShell:

Add-PnPCustomAction `
  -Title "StickyHeader" `
  -Name "StickyHeader" `
  -Location "ClientSideExtension.ApplicationCustomizer" `
  -ClientSideComponentId "your-component-id-from-manifest.json" `
  -ClientSideComponentProperties '{"logoUrl":"https://...","supportUrl":"https://..."}' `
  -Scope Site

📂 GitHub Source

View full SPFx project on GitHub:SPFx application customizer — sticky header with breadcrumb and responsive collapse

GitHub

✅ Summary

  • Subscribe to placeholderProvider.changedEvent — without it, your header disappears after SPA page navigation.
  • Use tryCreateContent not createContent — the placeholder may not exist on every page.
  • Unmount React trees in the dispose callbacks to prevent memory leaks in long-running sessions.
  • Pass properties via ClientSideComponentProperties in the UserCustomAction JSON — not hard-coded in the component.
  • Deploy by adding a UserCustomAction to the site or tenant — editors do not add this to individual pages.

Happy coding!

Ad image