Skip to main content
user@aqno:~$ cat how-i-fixed-the-sidebar-flash-on-my-static-site.md

How I Fixed the Sidebar Flash on My Static Site

Written by Giuseppe Aquino
Published on: 2026-03-11
astro, css, static-sites, frontend

My collapsed sidebar opened for one frame on every hard refresh.

The preference itself worked. Collapse the sidebar, reload the page, and the component eventually read "true" from localStorage. But the generated HTML still described the expanded state. The browser painted that state first, Preact hydrated, the component caught up, and the collapse transition played in public.

The fix was not another component flag. It was a small handoff across three layers: tell the document about the stored preference before paint, make CSS render the complete collapsed state, then let the hydrated component take ownership two frames later.

Hard loads forget browser-only state

Astro pre-renders this site to HTML. On a hard load or refresh, that HTML cannot know what this browser stored in localStorage because the value exists only on the client.

The sequence was:

  1. The server sends HTML. The sidebar is in its default state — expanded.
  2. The browser paints it.
  3. Preact loads and reads the stored preference.
  4. The sidebar changes to collapsed.
  5. CSS sees a state change and runs the transition.

Client-side navigation is not the problem here: aqno.dev uses Astro’s ClientRouter, and the sidebar persists across those navigations. The bad frame appears when the document starts from fresh HTML.

The transition was meant for a click. The browser had accidentally turned initialization into theater.

Set the preference before paint

The layout injects a tiny synchronous script in <head>. It runs before the bundled component code and adds data-sidebar-collapsed to <html> when the stored value is "true".

The storage key and generated script live together:

export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar-collapsed";

export function getSidebarCollapsedBootstrapScript(): string {
  const key = JSON.stringify(SIDEBAR_COLLAPSED_STORAGE_KEY);
  return [
    `if (localStorage.getItem(${key}) === "true") {`,
    '  document.documentElement.dataset.sidebarCollapsed = "";',
    "}",
  ].join("\n");
}

Layout.astro writes the returned string as a raw script:

<Fragment
  set:html={`<script>${getSidebarCollapsedBootstrapScript()}</script>`}
/>

Generating the script is not essential to the browser trick; a hand-written inline script works too. It is useful here because the component and bootstrap script share one storage-key constant, and a unit test executes the generated string directly.

Make the whole collapsed state match

Setting the attribute only helps if the pre-hydration CSS reproduces what the component will render.

My first version changed the sidebar width and stopped there. The clipped labels still occupied space, the icons shifted when Preact hydrated, and inactive icons changed color. The sidebar was technically collapsed and visually fidgeting.

The current guard covers every property that was visible during that window:

html[data-sidebar-collapsed] [data-sidebar],
html[data-sidebar-collapsed] [data-sidebar] * {
  transition-duration: 0s !important;
}

html[data-sidebar-collapsed] [data-sidebar] {
  width: 4rem !important;
}

html[data-sidebar-collapsed] [data-sidebar] [data-sidebar-collapsible] {
  opacity: 0 !important;
  max-width: 0 !important;
  max-height: 0 !important;
  margin: 0 !important;
}

html[data-sidebar-collapsed] [data-sidebar] [data-sidebar-header] {
  margin-bottom: 0 !important;
}

html[data-sidebar-collapsed] [data-sidebar] nav a {
  height: 2rem !important;
  padding-inline: 0.375rem !important;
}

html[data-sidebar-collapsed]
  [data-sidebar]
  nav
  a:not([aria-current="page"])
  svg {
  color: var(--color-text-secondary) !important;
}

These selectors are specific to this sidebar, but the rule generalizes: the bootstrap state must match the complete visible result, not just the largest box.

Hydrate, then remove the guard

The component still reads the same key and updates its own state. Once it has caught up, the temporary document attribute must disappear so later clicks get their normal animation.

One requestAnimationFrame was not enough. It fired before the browser had committed the style recalculation for the no-transition collapsed state, so removing the attribute there re-enabled the transition just in time for it to run anyway.

The handoff now waits for two frames:

useEffect(() => {
  const stored = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
  if (stored === "true") {
    setIsCollapsed(true);
  }

  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      delete document.documentElement.dataset.sidebarCollapsed;
    });
  });
}, []);

After that, the attribute is gone, the component owns the state, and toggles animate normally.

Verify the handoff, not just the script

The bootstrap helper has a unit test, but this bug lives at the boundary between HTML, CSS, hydration, and paint. The useful manual checks are:

  1. Collapse the sidebar and hard-refresh: it should paint collapsed with no label, spacing, icon-color, or transition flash.
  2. Expand it and hard-refresh: it should paint expanded.
  3. Toggle after hydration: the transition should still run.
  4. Navigate through the site with ClientRouter: the persisted sidebar should not reinitialize.
  5. Repeat with reduced motion enabled.

That last visual check matters. A test can prove that the attribute exists; it cannot prove that every pixel agrees with it.

If the server can read the preference

On an on-demand Astro route, a cookie can carry the preference to the server and let the layout render the matching attribute. That removes the need to read localStorage before paint.

It does not remove the need for one source of truth. The server-rendered attribute, the hydrated component’s initial state, and whatever the client writes on toggle must all agree. A cookie on the server plus unrelated localStorage state in the component just moves the flash to a more expensive address.

For this static site, the pre-paint bridge is the smaller tool.

The fix is a contract: the script reveals the stored preference, CSS tells the complete visual truth, and the component takes over only after the browser has committed it. No server required. Just no more lying on the first frame.

EOF