Theme-Specific Logos Without a Flash: Let CSS Pick, Not useTheme()
A logo with an opaque white background became a white block on the dark theme. Swapping it in CSS on data-theme, not with useTheme(), fixed it on the first frame.
The problem: a logo that only works in one theme
This site has a dark and a light theme. Project wordmarks are shown on cards and project pages, and some of the supplied logo files have an opaque background rather than a transparent one. The RPOMS mark is dark artwork on white. On the light theme it looked fine. On the dark theme it rendered as a white rectangle. Nothing in the file could fix that, because the background is part of the image.
The answer is a second file per theme. The interesting part is how to choose between them without the page showing the wrong one first. This note is for anyone with theme-specific images in a Next.js app using next-themes.
Why useTheme() is the wrong tool here
The obvious approach is a client component that reads useTheme() and renders one <Image> or the other. It has two costs. The theme is not known on the server, so the first render has to guess, and the correct image only appears after the provider mounts in the browser: a visible flash for anyone whose theme differs from the guess. And the card holding the logo has to become a client component just to make that choice.
But the theme is already known before first paint. next-themes is configured with attribute="data-theme", so it sets data-theme on the root element from a small inline script before the page renders. Anything CSS can select on that attribute is correct on the first frame. (The same layout-level defaults problem shows up in metadata too; see Next.js Open Graph Metadata Replaces, It Doesn't Merge.)
The fix: render both, let CSS hide one
Each project's image entry can carry an optional srcDark next to src. The RPOMS and Print Engine marks use it. When it is present, ProjectMark renders both images with classes that say which theme they belong to:
<Image src={src} alt={alt} width={width} height={height}
className={"theme-light-only " + className} />
<Image src={srcDark} alt={alt} width={width} height={height}
className={"theme-dark-only " + className} />The CSS treats dark as the foundation theme, so the dark variant shows by default and the light variant is opted into:
.theme-light-only {
display: none;
}
:root[data-theme="light"] .theme-light-only {
display: block;
}
:root[data-theme="light"] .theme-dark-only {
display: none;
}The component stays a server component, the right mark is painted on the first frame, and there is no hydration mismatch, because the server renders identical markup for both themes. A project without a srcDark renders a single image as before.
Accessibility: one logo announced, not two
Both images carry the same alt text, which would read twice if both were exposed. They are not: display: none removes an element from the accessibility tree as well as from the layout, so a screen reader announces exactly one wordmark in either theme. That is a reason to hide with display: none rather than with opacity or off-screen positioning, which would leave the hidden copy announced.
The trade-off, and when to use it
The cost is that both files are in the HTML and either may be downloaded. For small wordmarks that is trivial. For large photographs that differ by theme I would reach for a <picture> element with a prefers-color-scheme media query instead, accepting that it follows the system setting rather than a user's in-site choice.
For logos and icons, the pattern is cheap and robust: if the information is already on the root element before paint, select on it in CSS instead of reading it in JavaScript after hydration. The systems behind these two marks are RPOMS and the RPOMS Print Engine.
Keep reading
Next.js Open Graph Metadata Replaces, It Doesn't Merge
A child route that sets its own openGraph object silently drops the layout's og:image and og:locale. Why Next.js does that, and the helper this site uses to stop it.
Baseline Security Headers in Next.js, and Why the CSP Waited
Adding five response headers to a Next.js site that served none, why the Content-Security-Policy was deliberately deferred, and a nonce-free CSP on another app.
One Person, Referenced Everywhere: A JSON-LD @graph for a Personal Site
How this site declares its Person and WebSite nodes once and points every article, project page and the homepage at them by @id, and what that forced me to leave out.