When users browse a list of fonts, they want to see what each font looks like before clicking through to the detail page. The obvious solution: show a live preview on hover.
The non-obvious part: doing it without layout shifts, loading spinners, or janky transitions.
The problem
Font files are large. Even a single weight of a well-optimized variable font runs 20-50KB. When a user hovers over a card, loading the font creates a visible delay:
- User hovers
- Nothing happens (font loading)
- Text suddenly changes (jarring)
- User has already moved on
Worse, if the preview text changes height when the font loads (different x-height, line-height), the entire card shifts. On a grid of cards, this creates a cascade of layout shifts.
I needed:
- Instant visual feedback on hover
- No layout shift when fonts load
- Graceful degradation for slow connections
The state machine approach
I modeled the preview card as a state machine with four states:
stateDiagram-v2
[*] --> idle
idle --> warming : Font enters viewport
warming --> live : Font loaded + hover
warming --> error : Load failed
live --> warming : Mouse leave
error --> [*]
idle: Card shows static preview image. No font loaded.
warming: Font is loading in background. Card still shows image.
live: Font loaded. Card shows live text. Hover triggers instant preview.
error: Font failed to load. Stay on image forever.
The key insight: separate “loading” from “showing”. Start loading fonts before the user hovers, but only switch to live preview when they actually hover (and the font is ready).
The custom element
I implemented this as a custom element for encapsulation:
class FreeFontPreviewCard extends HTMLElement {
private state: 'idle' | 'warming' | 'live' | 'error' = 'idle';
private fontLoaded = false;
connectedCallback() {
this.setupHoverListeners();
}
private async warmFont() {
if (this.state !== 'idle') return;
this.state = 'warming';
try {
const fontFamily = this.dataset.fontFamily!;
await this.loadFont(fontFamily);
this.fontLoaded = true;
// Don't transition to 'live' yet - wait for hover
} catch {
this.state = 'error';
}
}
private showLivePreview() {
if (!this.fontLoaded) return;
this.state = 'live';
this.classList.add('preview-live');
}
private hideLivePreview() {
if (this.state !== 'live') return;
this.state = 'warming'; // Go back to warming, font is still loaded
this.classList.remove('preview-live');
}
}
customElements.define('free-font-preview-card', FreeFontPreviewCard);
The CSS handles the actual showing/hiding:
free-font-preview-card {
position: relative;
}
free-font-preview-card .preview-image {
opacity: 1;
transition: opacity 150ms ease-out;
}
free-font-preview-card .preview-text {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 150ms ease-out;
}
free-font-preview-card.preview-live .preview-image {
opacity: 0;
}
free-font-preview-card.preview-live .preview-text {
opacity: 1;
}
Crossfade, not swap. The text fades in as the image fades out. Since they’re absolutely positioned on top of each other, no layout shift.
IntersectionObserver prewarming
Waiting for hover to start loading creates perceptible delay. Instead, I prewarm fonts as cards scroll into view:
class FreeFontPreviewCard extends HTMLElement {
private static observer: IntersectionObserver;
private static observed = new Set<FreeFontPreviewCard>();
static {
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const card = entry.target as FreeFontPreviewCard;
card.warmFont();
// Stop observing once we've started warming
this.observer.unobserve(card);
this.observed.delete(card);
}
}
},
{
rootMargin: '200px', // Start loading 200px before visible
}
);
}
connectedCallback() {
FreeFontPreviewCard.observer.observe(this);
FreeFontPreviewCard.observed.add(this);
}
disconnectedCallback() {
FreeFontPreviewCard.observer.unobserve(this);
FreeFontPreviewCard.observed.delete(this);
}
}
The rootMargin: '200px' means fonts start loading when the card is 200px from entering the viewport. By the time users scroll to it and hover, the font is usually ready.
Font loader with deduplication
Multiple cards might use the same font family. Loading it twice wastes bandwidth. I use a deduplicating loader:
const fontLoadPromises = new Map<string, Promise<void>>();
async function loadFont(fontFamily: string): Promise<void> {
// Check if already loaded
if (document.fonts.check(`16px "${fontFamily}"`)) {
return;
}
// Check if currently loading
const existing = fontLoadPromises.get(fontFamily);
if (existing) {
return existing;
}
// Start new load
const promise = loadFontFromGoogle(fontFamily);
fontLoadPromises.set(fontFamily, promise);
try {
await promise;
} finally {
fontLoadPromises.delete(fontFamily);
}
}
async function loadFontFromGoogle(fontFamily: string): Promise<void> {
// Create link element for Google Fonts
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}&display=swap`;
document.head.appendChild(link);
// Wait for font to be ready
await document.fonts.load(`16px "${fontFamily}"`);
}
The document.fonts.check() call is synchronous and tells us if a font is already available. The document.fonts.load() call returns a promise that resolves when the font is ready.
Pointer media query: desktop only
Live preview on hover is a desktop pattern. On touch devices, there’s no hover state to trigger it. I use CSS media queries to disable the feature entirely on touch devices:
@media (pointer: fine) {
free-font-preview-card:hover .preview-image {
opacity: 0;
}
free-font-preview-card:hover .preview-text {
opacity: 1;
}
}
The pointer: fine media query matches devices with a precise pointing device (mouse, trackpad) but not touch screens.
On mobile, cards link directly to the font detail page. No hover complexity.
Preventing height changes
The trickiest part: keeping card height constant when switching from image to live text.
Font images have a fixed aspect ratio. Live text has variable height depending on the font’s metrics. The solution: constrain both to the same height and use overflow:
free-font-preview-card {
height: 200px; /* Fixed height */
overflow: hidden;
}
free-font-preview-card .preview-text {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
}
The live text is centered within the fixed container. If a font has extreme x-height and the text would overflow, it’s clipped. In practice, this rarely happens because I use short preview strings.
The preview text problem
What text should the live preview show?
Options I considered:
- “Hamburgefonts”: Typographer’s standard, shows key letters. Too jargon-y.
- Font name: “Inter” in Inter font. Circular but clear.
- Pangram: “The quick brown fox…” Overused, doesn’t test all characters.
- Random words: Unique per card. Inconsistent.
I went with option 2: the font name in the font itself. It’s self-documenting and creates visual variety across the grid.
<free-font-preview-card data-font-family="Inter">
<img class="preview-image" src="/preview/inter.webp" alt="Inter font preview" />
<div class="preview-text" style="font-family: 'Inter', sans-serif">
Inter
</div>
</free-font-preview-card>
Error handling
Fonts fail to load for various reasons:
- Network errors
- Google Fonts rate limiting
- Font doesn’t exist (typo in data)
- Browser doesn’t support the font format
When loading fails, the card stays on the image forever:
private async warmFont() {
try {
await this.loadFont(fontFamily);
this.fontLoaded = true;
} catch (error) {
this.state = 'error';
console.warn(`Failed to load font: ${fontFamily}`, error);
// Don't retry - stay on image
}
}
No error UI. No retry button. The user sees the static preview image, which is perfectly functional. They can still click through to the detail page.
Performance impact
Measurements on a page with 50 font cards:
| Metric | Before prewarming | After prewarming |
|---|---|---|
| First hover response | 800-1200ms | <50ms |
| Network requests | 50 (all at once) | ~10 (visible + buffer) |
| Memory usage | Lower (no fonts) | Higher (cached fonts) |
The prewarming strategy loads fonts incrementally as users scroll, rather than all at once. This spreads network load over time and prioritizes fonts the user is likely to see first.
Tradeoffs
What I gained:
- Instant hover previews (feels like native)
- No layout shifts during interaction
- Works across browsers without polyfills
What I lost:
- Extra bandwidth for font loading (mitigated by prewarming only visible cards)
- Memory usage increases as users scroll
- Custom elements require JS (no preview without it)
Decisions I’d reconsider:
- Prewarming could be smarter. Currently loads fonts for all visible cards. Could prioritize cards closer to cursor position.
- The 200px rootMargin is arbitrary. Should be based on typical scroll velocity.
The code
The complete custom element implementation is about 150 lines. The key patterns:
- State machine for predictable behavior
- IntersectionObserver for just-in-time loading
- Promise deduplication for network efficiency
- CSS transitions for smooth visual changes
- Media queries for device-appropriate behavior
No framework required. Works with any static site generator.