# Smooth Theme-Aware Images with CSS Transitions

til · 2025-08-12 · #meta

When [Ghostty](https://ghostty.org/) came out, I wrote [this blog post](/posts/enabling-font-ligatures-ghostty/) which contained a few screenshots. I forgot how I managed to have both light and dark mode work seamlessly in Eleventy, so I figured I'd write it down for future me or anyone that sees this.

## The Pattern

I use an Eleventy shortcode that conditionally renders different images based on theme preference. The basic version wraps images in figure tags with captions:

```typescript
config.addShortcode('image', (src: string, alt: string, withDarkMode = false) => {
  const lastDotIndex = src.lastIndexOf('.');
  const basePath = src.slice(0, lastDotIndex);
  const extension = src.slice(lastDotIndex);

  const lightPath = `${basePath}-light${extension}`;
  const darkPath = `${basePath}-dark${extension}`;

  return `<figure>${
    withDarkMode
      ? `<img class="not-dark:hidden" src="${darkPath}" alt="${alt}">` +
        `<img class="dark:hidden" src="${lightPath}" alt="${alt}">`
      : `<img src="${src}" alt="${alt}">`
  }<figcaption>${alt}</figcaption></figure>`;
});
```

## Smooth Transitions

The images would swap instantly when toggling themes, which felt jarring. Adding Tailwind's transition utilities creates a cross-fade effect:

```typescript
withDarkMode
  ? `<img class="not-dark:hidden not-dark:opacity-0 dark:opacity-100 starting:dark:opacity-0 transition-discrete transition-opacity duration-1000" src="${darkPath}" alt="${alt}">` +
    `<img class="dark:hidden dark:opacity-0 not-dark:opacity-100 starting:not-dark:opacity-0 transition-discrete transition-opacity duration-1000" src="${lightPath}" alt="${alt}">`
  : `<img src="${src}" alt="${alt}">`
```

The `starting:` modifiers prevent the fade animation on initial page load, which I borrowed from how Tailwind's own docs handle theme switching.

## Usage

Now `{% raw %}{% image "ghostty-screenshot.png" "Terminal with ligatures" true %}{% endraw %}` automatically looks for `ghostty-screenshot-light.png` and `ghostty-screenshot-dark.png`, then smoothly transitions between them when you toggle themes.

## Trade-offs

**Benefits:**
- Smooth 1-second cross-fade between theme variants
- Both images preload—no flicker on theme switch
- Simple to maintain with clear naming conventions

**Drawbacks:**
- Downloads both variants even if user never switches themes
- Requires maintaining two versions of every screenshot

## Notes

This approach works well for static sites where you control every image. For dynamic content or high-traffic sites, you'd want lazy loading for the hidden variant or CSS filters for automatic adjustments.

## References
Figured how to create custom shortcode after [reading this article](https://anhvn.com/posts/2022/markdown-optimizations) by [Anh](https://anhvn.com/).
