Astro Helpers
Prevent flash of unstyled content (FOUC) in Astro applications using inline scripts and CSS
What is FOUC?
Flash of Unstyled Content (FOUC) occurs when a web page appears briefly without styling before React components hydrate on the client side. In Astro applications that use React components with interactive features like collapsible menus or active states, the initial HTML is rendered without the dynamic styling that will be applied after hydration.
This causes visual "flashes" where:
- Menu items appear without proper active states
- Collapsible sections show in the wrong expanded/collapsed state
- Navigation highlights appear in the wrong location
- Images (especially logos and above-the-fold assets) load after the initial render
Why is FOUC Particularly Bad for Astro & SSG Sites?
Static Site Generation amplifies FOUC problems in ways that don't affect traditional server-rendered or SPA applications:
1. Build-Time vs Runtime State Mismatch
SSG sites generate HTML at build time, but user preferences (like sidebar collapsed state or theme) only exist at runtime. This creates an unavoidable gap between what's built and what the user expects to see.
2. No Server-Side Rendering for User Preferences
Unlike SSR frameworks that can read cookies server-side and render personalized HTML, SSG sites serve the same static HTML to everyone. User-specific state can only be applied client-side after the page loads.
3. Performance Goals Conflict with Dynamic State
SSG sites are optimized for instant first paint with minimal JavaScript. But this means dynamic state management loads later, creating visible transitions between the initial static render and the hydrated interactive version.
4. CDN Caching Amplifies the Problem
SSG sites are typically served from CDNs with aggressive caching. This means the static HTML loads incredibly fast, making any FOUC even more noticeable compared to slower SSR responses where rendering delays mask the transition.
How Does FOUC Happen in SSG?
The root cause is the gap between initial page render and React hydration:
- Build Time: Astro generates static HTML at build time with no user-specific state
- Initial Render: Static HTML with basic CSS is served
- HTML Loads: Browser displays the page with default styling
- React Loads: JavaScript bundle downloads and parses
- Hydration: React takes over and applies dynamic states
During steps 3-5, users see unstyled or incorrectly styled content. The solution is to use inline blocking scripts and CSS that execute before the page renders.
The Solution: Inline Scripts & CSS
Sofondo provides helper functions that generate inline scripts and CSS to prevent FOUC. These work by:
- Reading state from cookies/localStorage before the page renders
- Setting data attributes on the HTML element that CSS can target immediately
- Generating scoped CSS that works with both SSR and client-side rendering
Available Helper Functions
All helper functions are exported from @sofondo/react and can be used in Astro layouts.
getInlineStorageScript
Generates a blocking script that reads a value from cookies or localStorage and sets it as a data attribute on the HTML element.
import { getInlineStorageScript } from '@sofondo/react';
const script = getInlineStorageScript('sidebar:collapsed', 'auto');
// Reads the 'sidebar:collapsed' cookie/localStorage
// and sets data-sidebar-collapsed attributegetActiveStateScript & getActiveStateCSS
These helpers work together to prevent flash of active navigation states. The script determines which menu item is active based on the current URL, and the CSS styles it appropriately.
Critical: These helpers implement the three important fixes documented below to handle CSS Module scoping, header/sidebar conflicts, and submenu items.
import {
getActiveStateScript,
getActiveStateCSS
} from '@sofondo/react';
const sections = [{ id: 'docs', href: '/docs' }];
const menuItems = [
{ href: '/docs', exact: true },
{ href: '/docs/components' }
];
const script = getActiveStateScript(sections, menuItems);
const css = getActiveStateCSS(sections, menuItems);
// Sets data-active-section and data-active-path on HTML elementgetMenuExpansionScript & getMenuExpansionCSS
Generates scripts and CSS for multiple collapsible menu sections to prevent flash of incorrect expanded/collapsed states.
import {
getMenuExpansionScript,
getMenuExpansionCSS
} from '@sofondo/react';
const menuLabels = ['Examples', 'Components'];
const script = getMenuExpansionScript(menuLabels, 'auto');
const css = getMenuExpansionCSS(menuLabels);
// Sets data-menu-expanded-examples and
// data-menu-expanded-components attributesThree Critical Fixes
During development of this documentation site, we discovered and fixed three major FOUC issues in packages/react/src/utils/sidebarStorage.ts. Understanding these fixes will help you avoid similar problems.
Fix #1: Header Selector Scoping
Problem: The active section CSS selector was matching BOTH header AND sidebar navigation, causing the sidebar to display incorrect active states.
// This matches ANY <nav> element, including sidebar!
html[data-active-section="docs"] nav a[href="/docs"] {
background: var(--accent-primary);
color: white;
font-weight: 600;
}// Properly scoped to header > nav only
html[data-active-section="docs"] header nav a[href="/docs"] {
background: var(--accent-primary);
color: white;
font-weight: 600;
}Implementation: In packages/react/src/utils/sidebarStorage.ts:384, the selector was updated to include header to prevent matching sidebar elements.
Fix #2: CSS Module Scoping with Attribute Selectors
Problem: CSS Modules generate scoped class names like ._link_cizzh_47 at build time. Inline CSS cannot reference these dynamic class names, causing active states to not apply.
// CSS Module class names are unknown at SSR time
html[data-active-path="/docs"] aside nav .link {
color: var(--text-primary); // Won't match!
}
html[data-active-path="/docs"] aside nav .link::before {
background: rgba(66, 133, 244, 0.1); // Won't match!
}// Use href attribute selector instead of class names
html[data-active-path="/docs"] aside nav a[href="/docs"] {
color: var(--text-primary);
font-weight: 500;
}
html[data-active-path="/docs"] aside nav a[href="/docs"]::before {
background: rgba(66, 133, 244, 0.1);
}
html[data-active-path="/docs"] aside nav a[href="/docs"]::after {
content: '';
position: absolute;
left: -12px;
top: 12px;
width: 3px;
height: 20px;
background: var(--accent-primary);
border-radius: 0 4px 4px 0;
}Key Insight: Instead of trying to match CSS Module class names, we match the href attribute which is stable and known at render time.
Implementation: In packages/react/src/utils/sidebarStorage.ts:394-410, all CSS selectors were updated from .className to a[href="..."].
Fix #3: Missing Submenu CSS Generation
Problem: The getActiveStateCSS function only generated CSS for top-level menu items, not for nested submenu children. This caused submenu items like "Examples › Toast" to flash without proper active states.
export function getActiveStateCSS(sections, menuItems) {
// Only processes top-level items
const css = menuItems
.filter(item => item.href) // Skips items with children!
.map(item => `
html[data-active-path="${item.href}"] aside nav a[href="${item.href}"] {
color: var(--text-primary);
}
`)
.join('\n');
return css;
}export function getActiveStateCSS(sections, menuItems) {
// First, generate CSS for top-level items
const topLevelCSS = menuItems
.filter(item => item.href)
.map(item => `/* CSS for item */`)
.join('\n');
// Then, generate CSS for ALL submenu children
const submenuCSS = menuItems
.filter(item => item.children && item.children.length > 0)
.flatMap(parent => parent.children!) // Flatten all children
.filter(child => child.href) // Only children with hrefs
.map(child => `
html[data-active-path="${child.href}"] aside nav a[href="${child.href}"] {
color: var(--text-primary);
font-weight: 500;
}
html[data-active-path="${child.href}"] aside nav a[href="${child.href}"]::before {
background: rgba(66, 133, 244, 0.1);
}
html[data-active-path="${child.href}"] aside nav a[href="${child.href}"]::after {
content: '';
position: absolute;
left: -12px;
top: 12px;
width: 3px;
height: 20px;
background: var(--accent-primary);
border-radius: 0 4px 4px 0;
}
`)
.join('\n');
return topLevelCSS + '\n' + submenuCSS;
}Key Insight: Use .flatMap() to flatten nested menu structures, then generate CSS for each child independently.
Implementation: In packages/react/src/utils/sidebarStorage.ts:412-437, a new code block was added to process submenu children separately.
Complete Implementation Example
Here's how to implement all the helpers in an Astro layout. This example is from the actual BaseLayout.astro file used by this documentation site.
---
import {
getInlineStorageScript,
getMenuExpansionScript,
getMenuExpansionCSS,
getActiveStateScript,
getActiveStateCSS
} from '@sofondo/react';
// 1. Generate inline scripts and CSS to prevent flash
const sidebarScript = getInlineStorageScript('sidebar:collapsed', 'auto');
const menuExpansionScript = getMenuExpansionScript(['Examples'], 'auto');
const menuExpansionCSS = getMenuExpansionCSS(['Examples']);
const activeStateScript = getActiveStateScript(sections, menuItems);
const activeStateCSS = getActiveStateCSS(sections, menuItems);
---
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Inline CSS FIRST (executed before render) -->
<style is:inline set:html={menuExpansionCSS}></style>
<style is:inline set:html={activeStateCSS}></style>
<!-- Inline scripts SECOND (executed before render) -->
<script is:inline set:html={sidebarScript}></script>
<script is:inline set:html={menuExpansionScript}></script>
<script is:inline set:html={activeStateScript}></script>
</head>
<body>
<slot />
</body>
</html>Key Points:
- Order matters: CSS must come before scripts in the
<head> - Use is:inline: Astro's
is:inlineandset:htmlinject inline content - Blocking execution: Scripts execute synchronously before page render
Best Practices
- Test without JavaScript: Disable JS in your browser to verify inline CSS is working
- Use attribute selectors: Always prefer
[href="..."]over class names in inline CSS - Flatten nested structures: Process submenu items separately to ensure complete coverage
- Scope selectors carefully: Use parent selectors like
headerandasideto avoid conflicts - Monitor performance: Inline scripts are fast, but keep them minimal
- Version your CSS: When updating active state styles, update both React components AND inline CSS
Debugging Tips
If you're seeing flash issues:
- Check data attributes: Use browser DevTools to inspect the
<html>element for correct data attributes - Verify selector specificity: Ensure inline CSS selectors match your actual DOM structure
- Test SSR output: View page source (not DevTools) to see what HTML is initially rendered
- Compare class names: If using CSS Modules, verify your selectors use attributes instead of classes
- Check submenu rendering: Ensure nested menu items receive proper CSS generation