Skip to content
Chapter 28Lesson 7

Hero with a flicker-free theme-aware image

The hero is the headline band at the top of the page: a big claim, two call-to-action buttons, and a marketing image of the product. Your job is to make that image already match the visitor’s theme the instant the page paints — a dark screenshot for someone in dark mode, a light one for everyone else, with no flicker where the wrong image shows for a frame and then snaps to the right one.

The finished hero at desktop width — copy and the theme-aware marketing image sit side by side at lg, with one h1 and two CTAs; the image already matches the active theme on first paint.

At lg the copy sits on the left and the image on the right; below that the two stack with the copy on top. Flip your OS theme from light to dark and the image swaps — no flash, no shift in the layout.

The hero carries more weight than its size suggests. It owns the page’s single <h1>, which anchors the heading hierarchy the rest of the surface hangs off — the feature grid’s <h2> you ship in the next lesson must not skip a level, so there can be exactly one <h1> and it lives here. It is also where the theme story becomes visible for the first time, so it has to honor the no-flash commitment this project made from the very first frame.

That commitment is the real lesson, and it turns on one decision an experienced engineer makes here. The naive instinct is to read the current theme in JavaScript and render a single <img> with the matching source. That single image cannot exist until JavaScript runs, which is after the first paint — so the page paints with no image (or the wrong one) and then snaps, and that snap is the flash you are trying to kill. The move instead is to render both theme images server-side and let CSS pick the visible one. The correct image is already in the HTML, and the browser hides the other one before any of your JavaScript executes. No branch runs before the right pixels are on screen.

The constraint that shapes the markup: the swap must track the site’s .dark class — the class next-themes flips on <html> — not the raw OS prefers-color-scheme media query. They agree today because the theme defaults to system, but in a later lesson you wire a manual toggle, and from that point a visitor can choose dark on a light OS. Key the image off the class and it obeys that choice for free; key it off the media query and it ignores the toggle. Build the swap with Tailwind’s dark: variant, which is exactly the class-driven hook.

Two things are out of scope. A single <picture> with a prefers-color-scheme source is a real technique, but it tracks the OS signal, which is the wrong one here. And next/image optimization arrives in the next unit — raw <img> is deliberate for this project, so reach for plain image tags.

The hero renders exactly one <h1>, the supporting copy, and two working CTA buttons that navigate.
The marketing image shown matches the active theme.
Toggling the theme swaps the image with no flash and no layout shift.
At lg the copy and image sit side by side; below lg they stack with no horizontal scroll.
Tabbing reaches both CTAs in order, each with a visible focus ring.

Build src/components/theme-aware-image.tsx and src/components/hero.tsx against the brief and the tests, then open the solution below to compare.

Reference solution and walkthrough

Start with theme-aware-image.tsx, because the hero consumes it. This is the primitive that carries the whole no-flash mechanism, so it is worth reading one piece at a time.

import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export type ThemeAwareImageProps = {
light: string;
dark: string;
alt: string;
width: number;
height: number;
} & ComponentProps<'img'>;
export const ThemeAwareImage = ({
light,
dark,
alt,
width,
height,
className,
...props
}: ThemeAwareImageProps) => (
<>
<img
data-testid="hero-image-light"
src={light}
alt={alt}
width={width}
height={height}
className={cn('block dark:hidden', className)}
{...props}
/>
<img
data-testid="hero-image-dark"
src={dark}
alt={alt}
width={width}
height={height}
className={cn('hidden dark:block', className)}
{...props}
/>
</>
);

The props. light and dark are the two image sources; alt, width, and height are shared by both, and width/height are required (not optional) because they reserve the layout box. Intersecting with ComponentProps<'img'> lets a caller pass any other <img> attribute through.

import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export type ThemeAwareImageProps = {
light: string;
dark: string;
alt: string;
width: number;
height: number;
} & ComponentProps<'img'>;
export const ThemeAwareImage = ({
light,
dark,
alt,
width,
height,
className,
...props
}: ThemeAwareImageProps) => (
<>
<img
data-testid="hero-image-light"
src={light}
alt={alt}
width={width}
height={height}
className={cn('block dark:hidden', className)}
{...props}
/>
<img
data-testid="hero-image-dark"
src={dark}
alt={alt}
width={width}
height={height}
className={cn('hidden dark:block', className)}
{...props}
/>
</>
);

The destructure pulls className out on its own so it can be merged into each <img> individually, and gathers everything else into ...props to spread onto both.

import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export type ThemeAwareImageProps = {
light: string;
dark: string;
alt: string;
width: number;
height: number;
} & ComponentProps<'img'>;
export const ThemeAwareImage = ({
light,
dark,
alt,
width,
height,
className,
...props
}: ThemeAwareImageProps) => (
<>
<img
data-testid="hero-image-light"
src={light}
alt={alt}
width={width}
height={height}
className={cn('block dark:hidden', className)}
{...props}
/>
<img
data-testid="hero-image-dark"
src={dark}
alt={alt}
width={width}
height={height}
className={cn('hidden dark:block', className)}
{...props}
/>
</>
);

The light source. block dark:hidden means it is visible by default and hidden the moment the .dark class lands on <html>. This is the image a visitor in light mode sees.

import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export type ThemeAwareImageProps = {
light: string;
dark: string;
alt: string;
width: number;
height: number;
} & ComponentProps<'img'>;
export const ThemeAwareImage = ({
light,
dark,
alt,
width,
height,
className,
...props
}: ThemeAwareImageProps) => (
<>
<img
data-testid="hero-image-light"
src={light}
alt={alt}
width={width}
height={height}
className={cn('block dark:hidden', className)}
{...props}
/>
<img
data-testid="hero-image-dark"
src={dark}
alt={alt}
width={width}
height={height}
className={cn('hidden dark:block', className)}
{...props}
/>
</>
);

The dark source — the exact mirror. hidden dark:block keeps it out until .dark is on, then reveals it. The two images are never both visible; the .dark class alone decides which one shows.

import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
export type ThemeAwareImageProps = {
light: string;
dark: string;
alt: string;
width: number;
height: number;
} & ComponentProps<'img'>;
export const ThemeAwareImage = ({
light,
dark,
alt,
width,
height,
className,
...props
}: ThemeAwareImageProps) => (
<>
<img
data-testid="hero-image-light"
src={light}
alt={alt}
width={width}
height={height}
className={cn('block dark:hidden', className)}
{...props}
/>
<img
data-testid="hero-image-dark"
src={dark}
alt={alt}
width={width}
height={height}
className={cn('hidden dark:block', className)}
{...props}
/>
</>
);

The fragment ships both <img> tags to the browser. Both are in the HTML on first paint, and CSS — not JavaScript — picks the visible one. That is the whole trick: the right image is on screen before any of your code runs.

1 / 1

The detail that makes this work is that both <img> tags ship to the client. The server renders both sources into the HTML; the .dark class is already present on <html> — the next-themes pre-paint script set it before the body rendered — so the browser hides the wrong one as it parses the page, in the same pass that paints the first frame. There is no moment where the wrong image is visible. A JavaScript-gated single image cannot match that — it would have nothing to show until hydration, and the swap-in is the flash.

It also means the image honors the .dark class, not the OS preference. The dark: variant compiles to a selector that keys off that class, so once you ship the manual toggle in a later lesson, the image follows the toggle, not just the operating system.

One thing looks redundant and is not: shipping two <img> tags for a single visual slot is intentional. A reviewer scanning the diff might be tempted to “simplify” it down to one — name it in the PR so nobody does. The duplication is the no-flash mechanism.

Now hero.tsx, which composes that primitive into the band. It is one coherent block, so read it straight through; the subtle parts are called out underneath.

src/components/hero.tsx
import Link from 'next/link';
import { ThemeAwareImage } from '@/components/theme-aware-image';
import { Button } from '@/components/ui/button';
export const Hero = () => (
<section
data-testid="hero"
className="container mx-auto grid items-center gap-12 px-4 py-16 lg:grid-cols-2 lg:py-24"
>
<div className="flex flex-col items-start gap-6">
<h1 className="text-4xl font-bold tracking-tight text-balance text-foreground sm:text-5xl lg:text-6xl">
The themed product surface your users feel.
</h1>
<p className="max-w-prose text-lg text-pretty text-muted-foreground">
Acme ships an accessible, responsive marketing surface with
byte-identical light and dark themes — so you launch polished from the
very first paint.
</p>
<div className="flex flex-wrap gap-4">
<Button asChild size="lg">
<Link href="#signup">Start free trial</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="#features">See features</Link>
</Button>
</div>
</div>
<ThemeAwareImage
light="/hero-light.png"
dark="/hero-dark.png"
alt="A preview of the Acme product dashboard"
width={1200}
height={800}
className="h-auto w-full rounded-xl border border-border"
/>
</section>
);

A few decisions are worth pausing on.

The <section> is a grid that becomes two columns at lg:grid-cols-2 and is a single column below — so at lg the copy and image sit side by side, and below lg they stack, the copy column first. The gap-12 keeps them apart without margins, and items-center vertically centers the image against the copy.

There is exactly one <h1>, and it carries text-balance plus a fluid size that steps up at sm: and lg:. This is the page’s only first-level heading by design; the semantic-heading rule is why the next lesson’s feature section opens with an <h2> instead of a second <h1>.

Each CTA is a <Button asChild> wrapping a <Link>. asChild is the Slot polymorphism the Button exposes: instead of rendering its own <button>, it hands its styling to the child element — here, Next.js’s <Link> — so you get a real anchor that navigates, dressed as a button. The second CTA is variant="outline" to read as secondary. The Button’s focus-visible ring comes from its base classes, which is what gives you a visible focus ring on each CTA when you tab.

The ThemeAwareImage is passed width={1200} and height={800}. Those land on both <img> tags and reserve the layout box, so neither the image loading nor the theme swap shifts anything around it — the slot is the right size before a single byte of the image arrives. The className merges through to each source via cn(), the class-merge helper, making the image fill its column with a rounded border.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 7

The suite renders the hero to its server-side HTML — the exact markup of the first paint — and checks the no-flash promise from that markup: both theme images present as siblings, pointing at different files, the light one visible by default (block dark:hidden) and the dark one hidden by default (hidden dark:block), sharing the same alt, width, and height. It also confirms exactly one <h1>, supporting copy, and two CTAs with non-empty hrefs. A green run looks like this:

✓ tests/lessons/Lesson 7.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)

Then run the full gate — Biome, the type-checker, and a production build:

Terminal window
pnpm verify

The tests can prove both images are in the HTML, but they cannot watch a real browser repaint, so the no-flash and no-shift properties — and the visual reflow — are yours to confirm by hand. Boot the page with pnpm dev and work through these:

Set the OS theme to dark, then back to light; the correct hero image is showing each time.
untested
Record a DevTools Performance reload in each theme and confirm there is no flash frame as the image resolves.
untested
At lg the copy and image sit side by side; below lg they stack with no horizontal scroll.
untested
Tab from the URL bar reaches both CTAs in order, each with a visible focus ring; Enter navigates.
untested