Project thumbnails on this site default to grayscale and return to full color when the user hovers or focuses the card. The effect is subtle — it keeps the grid calm while still rewarding exploration. The implementation is a single utility class in global CSS, applied through a tiny helper so JSX stays consistent.
The content-image class
Styles live in src/app/global.css under .content-image. The React helper exports a constant class name from src/lib/content-image.ts so media components do not hard-code strings.
/* global.css (excerpt) */
.content-image {
transition: filter 300ms ease;
}
@media (hover: hover) and (pointer: fine) {
.content-image {
filter: grayscale(1);
}
.content-image:hover,
.group:hover .content-image,
.group:focus-within .content-image,
.group:focus-visible .content-image {
filter: grayscale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.content-image {
transition: none;
}
}Why hover and focus-within
Pointer users get color on hover. Keyboard users need the same affordance when focus moves inside a project link, so the selectors include .group:focus-within and .group:focus-visible on the card wrapper. The card is a single interactive surface (Link with group), which keeps focus management simple.
Touch-first devices
The grayscale filter is wrapped in @media (hover: hover) and (pointer: fine). On phones and tablets without a fine pointer, images stay in color all the time — there is no hover state to discover, so forcing grayscale would only reduce clarity.
Reduced motion
When prefers-reduced-motion: reduce is set, the transition on the filter is disabled. Color may still change instantly on hover where hover is available; the important part is avoiding animated filter transitions for users who asked for less motion.
Using it on new media
Any screenshot or preview image inside a project card should use the helper class on the img or media wrapper. Do not duplicate filter logic in Tailwind arbitrary values — one CSS block keeps behavior aligned with accessibility media queries.
import { contentImageClass } from "@/lib/content-image";
<img
src={src}
alt={alt}
className={contentImageClass}
/>If you add motion beyond filter (scale, parallax), gate those effects behind the same reduced-motion query or skip them entirely. The grayscale pattern is intentionally boring: predictable, testable, and easy to explain in a design review.