- 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:
| Placeholder | Position | Notes |
|---|---|---|
PlaceholderName.Top | Above the SharePoint suite bar and page content | Use for headers, nav bars, notification banners |
PlaceholderName.Bottom | Below all page content, at the very bottom of the viewport | Use 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
changedEventso the header re-renders after SharePoint's SPA navigation. Without this, navigating between pages clears the placeholder and the header disappears. - Call
tryCreateContent— notcreateContent. If the placeholder does not exist on the current page (some SharePoint system pages suppress placeholders),tryCreateContentreturnsundefinedsafely. - 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;
🧩 Site Footer Component
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
✅ Summary
- Subscribe to
placeholderProvider.changedEvent— without it, your header disappears after SPA page navigation. - Use
tryCreateContentnotcreateContent— 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
ClientSideComponentPropertiesin 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!
Author
Ravichandran@Hi_Ravichandran
