Loading multiple fonts can slow down initial page loads. When building PostNitro's custom fonts feature, we needed a way to lazily load fonts without impacting performance.
In this post, we'll use web components in Next.js and TypeScript to optimize font loading.
The Problem: Massive Font Files Hurting Site Speed
In PostNitro.ai, users can upload custom fonts and choose from them in a dropdown. To provide a better user experience, we wanted to show font previews in the dropdown. However, loading all the font files upfront caused painfully slow initial page loads.
Our goal was to find a way to dynamically load fonts only when users needed to preview them in the font selection dropdown. This led us to web components.
Why Web Components Are Ideal for Lazy Font Loading
Web components let you build reusable, encapsulated HTML elements with JavaScript logic attached. The browser loads a web component's code only when it encounters the custom element in the DOM.
This enabled us to create a <font-previewer>
component that fetches and applies fonts on demand. The font files will load only within the scope of the element.
By conditionally loading fonts through web components, we avoid the performance pitfalls of serving massive font files to every user upfront.
Step-by-Step: Building the Font Previewer Web Component
Here's how we built the <font-previewer>
element to lazily load fonts:
- Accept
font-URL
andfont-family
attributes - Use the FontFace API to dynamically load the font file
- Apply the font to the component's shadow DOM
- Re-render when attributes change to preview different fonts
Encapsulating the font loading logic in a web component allows us to optimize performance by only loading fonts as needed for the previews in Postnitro.
Here is the complete code for the web component file (FontPreviewer.ts):
// Interface defining properties for the FontPreviewer
interface IFontPreviewer {
'font-url': string;
'font-family': string;
}
// Custom Element Definition
class FontPreviewer extends HTMLElement implements IFontPreviewer {
// List of attributes to observe for changes
static get observedAttributes() {
return ['font-url', 'font-family'];
}
// Private variables holding the values of the custom element's attributes
private _fontUrl: string = '';
private _fontFamily: string = '';
constructor() {
super();
// Attaching a shadow root to the custom element
this.attachShadow({mode: 'open'});
}
// Getter and setter for font-URL attribute
get 'font-url' () {
return this._fontUrl;
}
set 'font-url'(val: string) {
this._fontUrl = val;
}
// Getter and setter for font-family attribute
get 'font-family' () {
return this._fontFamily;
}
set 'font-family'(val: string) {
this._fontFamily = val;
}
// Lifecycle method called when the custom element is added to the DOM
connectedCallback() {
this.render();
}
// Lifecycle method called when an observed attribute changes
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) {
// Update the property and re-render the custom element
(this as any)[name] = newValue;
this.render();
}
}
// Render method
render() {
// Create a new FontFace object and load the font
const fontFace = new FontFace(this['font-family'], `url(${this['font-url']})`, {
style: 'normal', unicodeRange: 'U+000-5FF', weight: '400'
});
fontFace.load().then((loaded_face) => {
// Add the loaded font to the document fonts
document.fonts.add(loaded_face);
// Fill the shadow root with the custom element's template
if(this.shadowRoot) {
this.shadowRoot.innerHTML = `
<style>
::slotted(*), div {
font-family: '${this['font-family']}', sans-serif;
font-size: 18px;
}
</style>
<slot><div>${this['font-family']}</div></slot>
`;
}
}).catch(function(error) {
// Log any error occurred during the font loading
console.log("Failed to load font: " + error);
});
}
}
// Register the custom element with the browser
if (!window.customElements.get('font-previewer'))
window.customElements.define('font-previewer', FontPreviewer);
Using the Web Component in a Next.js App
To use the <font-previewer>
element in a Next.js app:
1. Import the .ts
file:
Importing the web component into your Next.js application might trigger an error: ReferenceError: HTMLElement
is not defined.
This stitches from the fact that web components don't traditionally support SSR. However, this issue can be effectively catered to by importing the component in the following manner:
React.useEffect(() => {
import("@/web_components/FontPreviewer.ts").then((FontPreviewer) => {
//Do something with FontPreviewer (Optional)
});
}, [])
2. Use it in your JSX:
<font-previewer
font-url="https://example.com/font.otf"
font-family="Custom Font Name">
Text preview
</font-previewer>
The <font-previewer>
element will take care of fetching the font file and applying it to the inner content.
3. Rectify TypeScript Error:
When you use TypeScript with JSX, it tries to check every element you use to guard against typos. This is a problem when you've just registered a component that of course doesn't exist in the normal DOM:
Property 'font-previewer' does not exist on type 'JSX.IntrinsicElements'.ts(2339)
Luckily, the solution to this problem already exists. By means of declaration merging, TypeScript can be extended:
// declarations.d.ts - place it anywhere, this is an ambient module
declare global {
namespace JSX {
interface IntrinsicElements {
'font-previewer': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
}
}
}
Key Takeaways
- Web components enable lazy font loading to optimize Next.js performance
- The <font-previewer> element encapsulates font logic away from the app
- Conditional loading prevents serving massive fonts to every user
- Reusable web components create efficient, optimized UI widgets
Let us know if you need any other clarifications or have additional feedback!
About Seerat Awan
Seerat Awan, Co-Founder & CTO at PostNitro Inc. My role is to be the engineering powerhouse architecting the core capabilities of our platform.