Grayscale-to-Color Contrast Reveal
Team bios and client logo walls that bloom into colour as they reach the middle of the viewport. The demo that teaches the cover range — colour peaks mid-screen rather than on arrival — plus the filter-list interpolation rule that breaks most hand-written filter animations, and hover parity so pointer users get the same payoff.
Published
The code
<section class="rv-08" tabindex="0" role="region" aria-label="Grayscale to colour reveal demo">
<p class="rv-08__hint">scroll inside — colour peaks mid-viewport <span aria-hidden="true">↓</span></p>
<ul class="rv-08__team">
<li style="--i:0">
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Maya, design lead" width="560" height="700" loading="lazy" decoding="async">
<span>Maya · design</span>
</li>
<li style="--i:1">
<img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Tom, engineering lead" width="560" height="700" loading="lazy" decoding="async">
<span>Tom · engineering</span>
</li>
<li style="--i:2">
<img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Ada, research lead" width="560" height="700" loading="lazy" decoding="async">
<span>Ada · research</span>
</li>
</ul>
<p class="rv-08__note">range: cover 30% → 55% · not entry</p>
<div class="rv-08__uc">
<p class="rv-08__uclabel"><b>real-world use case</b> about page · team & leadership</p>
<div class="shell pad">
<div class="head">
<div class="col"><p class="eyebrow">The team</p><h4>Fourteen people. No account managers.</h4></div>
<span class="where">Bristol · remote-first</span>
</div>
<ul class="rv-08__team roster">
<li style="--i:0"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=440&q=68" alt="Portrait of Maya Okonjo, design lead" width="440" height="550" loading="lazy" decoding="async"><b>Maya Okonjo</b><span>Design lead · 9 yrs</span></li>
<li style="--i:1"><img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=440&q=68" alt="Portrait of Tom Reyes, engineering lead" width="440" height="550" loading="lazy" decoding="async"><b>Tom Reyes</b><span>Engineering · 12 yrs</span></li>
<li style="--i:2"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=440&q=68" alt="Portrait of Ada Lindqvist, research lead" width="440" height="550" loading="lazy" decoding="async"><b>Ada Lindqvist</b><span>Research · 7 yrs</span></li>
<li style="--i:3"><img src="https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?auto=format&fit=crop&w=440&q=68" alt="Portrait of Sam Herrera, delivery lead" width="440" height="550" loading="lazy" decoding="async"><b>Sam Herrera</b><span>Delivery · 6 yrs</span></li>
</ul>
</div>
<p class="why">Colour peaks as each face reaches the middle of the screen, so attention and saturation move together — and the names carry the information, never the colour.</p>
</div>
</section><section class="rv-08" tabindex="0" role="region" aria-label="Grayscale to colour reveal demo">
<p class="rv-08__hint">scroll inside — colour peaks mid-viewport <span aria-hidden="true">↓</span></p>
<ul class="rv-08__team">
<li style="--i:0">
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Maya, design lead" width="560" height="700" loading="lazy" decoding="async">
<span>Maya · design</span>
</li>
<li style="--i:1">
<img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Tom, engineering lead" width="560" height="700" loading="lazy" decoding="async">
<span>Tom · engineering</span>
</li>
<li style="--i:2">
<img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Ada, research lead" width="560" height="700" loading="lazy" decoding="async">
<span>Ada · research</span>
</li>
</ul>
<p class="rv-08__note">range: cover 30% → 55% · not entry</p>
<div class="rv-08__uc">
<p class="rv-08__uclabel"><b>real-world use case</b> about page · team & leadership</p>
<div class="shell pad">
<div class="head">
<div class="col"><p class="eyebrow">The team</p><h4>Fourteen people. No account managers.</h4></div>
<span class="where">Bristol · remote-first</span>
</div>
<ul class="rv-08__team roster">
<li style="--i:0"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=440&q=68" alt="Portrait of Maya Okonjo, design lead" width="440" height="550" loading="lazy" decoding="async"><b>Maya Okonjo</b><span>Design lead · 9 yrs</span></li>
<li style="--i:1"><img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=440&q=68" alt="Portrait of Tom Reyes, engineering lead" width="440" height="550" loading="lazy" decoding="async"><b>Tom Reyes</b><span>Engineering · 12 yrs</span></li>
<li style="--i:2"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=440&q=68" alt="Portrait of Ada Lindqvist, research lead" width="440" height="550" loading="lazy" decoding="async"><b>Ada Lindqvist</b><span>Research · 7 yrs</span></li>
<li style="--i:3"><img src="https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?auto=format&fit=crop&w=440&q=68" alt="Portrait of Sam Herrera, delivery lead" width="440" height="550" loading="lazy" decoding="async"><b>Sam Herrera</b><span>Delivery · 6 yrs</span></li>
</ul>
</div>
<p class="why">Colour peaks as each face reaches the middle of the screen, so attention and saturation move together — and the names carry the information, never the colour.</p>
</div>
</section>.rv-08 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 20px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-08 * {
box-sizing: border-box;
}
.rv-08__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
.rv-08__team {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(120px,1fr));
gap: 12px;
list-style: none;
margin: 60% 0 0;
padding: 0;
}
.rv-08__team li {
display: grid;
gap: 8px;
}
.rv-08 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4/5;
object-fit: cover;
border-radius: 12px;
}
.rv-08__team span {
font: 500 11px/1.3 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
.rv-08__note {
margin: 14px 0 30%;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── the bloom: COVER range, so colour peaks mid-viewport ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-08 img {
animation: rv08-colour linear both;
animation-timeline: view();
animation-range: cover calc(30% + var(--i) * 2%) cover calc(55% + var(--i) * 2%);
}
}
}
/* filter lists interpolate POSITIONALLY — same functions, same order, both keyframes */
@keyframes rv08-colour {
from {
filter: grayscale(1) brightness(.72) contrast(1.08);
}
to {
filter: grayscale(0) brightness(1) contrast(1);
}
}
/* pointer users get the same payoff; hover query keeps touch clean */
@media (hover: hover) {
.rv-08 img:hover {
filter: grayscale(0) brightness(1.03) contrast(1);
}
}
/* ── fallback: --c is distance from viewport centre, written below ── */
@supports not (animation-timeline: view()) {
.rv-08 img {
filter: grayscale(calc(1 - var(--c,0))) brightness(calc(.72 + .28 * var(--c,0))) contrast(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-08 img {
filter: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-08__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-08__uc h4 {
margin: 0;
font: 600 clamp(19px,2.6vw,30px)/1.14 system-ui,sans-serif;
letter-spacing: -.026em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-08__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-08__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-08__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-08__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-08__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-08__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-08__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-08__uc .col {
display: grid;
gap: 6px;
}
.rv-08__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-08__uc {
margin: 0 0 110%;
}
.rv-08__uc .shell.pad {
padding: 22px;
}
.rv-08__uc .head {
display: flex;
gap: 16px;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rv-08__uc .where {
font: 400 12px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uc .roster {
grid-template-columns: repeat(auto-fit,minmax(125px,1fr));
gap: 14px;
margin: 0;
}
.rv-08__uc .roster li {
gap: 10px;
}
.rv-08__uc .roster b {
font: 600 13.5px/1.3 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .roster span {
font: 400 11.5px/1.3 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}.rv-08 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 20px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-08 * {
box-sizing: border-box;
}
.rv-08__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
.rv-08__team {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(120px,1fr));
gap: 12px;
list-style: none;
margin: 60% 0 0;
padding: 0;
}
.rv-08__team li {
display: grid;
gap: 8px;
}
.rv-08 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4/5;
object-fit: cover;
border-radius: 12px;
}
.rv-08__team span {
font: 500 11px/1.3 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
.rv-08__note {
margin: 14px 0 30%;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── the bloom: COVER range, so colour peaks mid-viewport ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-08 img {
animation: rv08-colour linear both;
animation-timeline: view();
animation-range: cover calc(30% + var(--i) * 2%) cover calc(55% + var(--i) * 2%);
}
}
}
/* filter lists interpolate POSITIONALLY — same functions, same order, both keyframes */
@keyframes rv08-colour {
from {
filter: grayscale(1) brightness(.72) contrast(1.08);
}
to {
filter: grayscale(0) brightness(1) contrast(1);
}
}
/* pointer users get the same payoff; hover query keeps touch clean */
@media (hover: hover) {
.rv-08 img:hover {
filter: grayscale(0) brightness(1.03) contrast(1);
}
}
/* ── fallback: --c is distance from viewport centre, written below ── */
@supports not (animation-timeline: view()) {
.rv-08 img {
filter: grayscale(calc(1 - var(--c,0))) brightness(calc(.72 + .28 * var(--c,0))) contrast(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-08 img {
filter: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-08__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-08__uc h4 {
margin: 0;
font: 600 clamp(19px,2.6vw,30px)/1.14 system-ui,sans-serif;
letter-spacing: -.026em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-08__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-08__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-08__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-08__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-08__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-08__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-08__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-08__uc .col {
display: grid;
gap: 6px;
}
.rv-08__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-08__uc {
margin: 0 0 110%;
}
.rv-08__uc .shell.pad {
padding: 22px;
}
.rv-08__uc .head {
display: flex;
gap: 16px;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rv-08__uc .where {
font: 400 12px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uc .roster {
grid-template-columns: repeat(auto-fit,minmax(125px,1fr));
gap: 14px;
margin: 0;
}
.rv-08__uc .roster li {
gap: 10px;
}
.rv-08__uc .roster b {
font: 600 13.5px/1.3 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .roster span {
font: 400 11.5px/1.3 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}/* OPTIONAL — progressive enhancement only. Reproduces the cover-range behaviour by writing a
0-1 value based on distance from the scroller's vertical centre. */
if (!CSS.supports('animation-timeline: view()')) {
const scroller = document.querySelector('.rv-08');
const imgs = [...scroller.querySelectorAll('img')];
let queued = false;
const update = () => {
queued = false;
const box = scroller.getBoundingClientRect();
const mid = box.top + box.height / 2;
for (const img of imgs) {
const r = img.getBoundingClientRect();
const dist = Math.abs((r.top + r.height / 2) - mid) / (box.height / 2);
img.style.setProperty('--c', (1 - Math.min(1, dist)).toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
scroller.addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}/* OPTIONAL — progressive enhancement only. Reproduces the cover-range behaviour by writing a
0-1 value based on distance from the scroller's vertical centre. */
if (!CSS.supports('animation-timeline: view()')) {
const scroller = document.querySelector('.rv-08');
const imgs = [...scroller.querySelectorAll('img')];
let queued = false;
const update = () => {
queued = false;
const box = scroller.getBoundingClientRect();
const mid = box.top + box.height / 2;
for (const img of imgs) {
const r = img.getBoundingClientRect();
const dist = Math.abs((r.top + r.height / 2) - mid) / (box.height / 2);
img.style.setProperty('--c', (1 - Math.min(1, dist)).toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
scroller.addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}Here's a working CSS Image Reveal on Scroll from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Grayscale-to-Color Contrast Reveal
Source: https://codefronts.com/motion/css-image-reveal-scroll/grayscale-to-color-contrast-reveal/
Team bios and client logo walls that bloom into colour as they reach the middle of the viewport. The demo that teaches the cover range — colour peaks mid-screen rather than on arrival — plus the filter-list interpolation rule that breaks most hand-written filter animations, and hover parity so pointer users get the same payoff.
## HTML
```html
<section class="rv-08" tabindex="0" role="region" aria-label="Grayscale to colour reveal demo">
<p class="rv-08__hint">scroll inside — colour peaks mid-viewport <span aria-hidden="true">↓</span></p>
<ul class="rv-08__team">
<li style="--i:0">
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Maya, design lead" width="560" height="700" loading="lazy" decoding="async">
<span>Maya · design</span>
</li>
<li style="--i:1">
<img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Tom, engineering lead" width="560" height="700" loading="lazy" decoding="async">
<span>Tom · engineering</span>
</li>
<li style="--i:2">
<img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Ada, research lead" width="560" height="700" loading="lazy" decoding="async">
<span>Ada · research</span>
</li>
</ul>
<p class="rv-08__note">range: cover 30% → 55% · not entry</p>
<div class="rv-08__uc">
<p class="rv-08__uclabel"><b>real-world use case</b> about page · team & leadership</p>
<div class="shell pad">
<div class="head">
<div class="col"><p class="eyebrow">The team</p><h4>Fourteen people. No account managers.</h4></div>
<span class="where">Bristol · remote-first</span>
</div>
<ul class="rv-08__team roster">
<li style="--i:0"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=440&q=68" alt="Portrait of Maya Okonjo, design lead" width="440" height="550" loading="lazy" decoding="async"><b>Maya Okonjo</b><span>Design lead · 9 yrs</span></li>
<li style="--i:1"><img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=440&q=68" alt="Portrait of Tom Reyes, engineering lead" width="440" height="550" loading="lazy" decoding="async"><b>Tom Reyes</b><span>Engineering · 12 yrs</span></li>
<li style="--i:2"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=440&q=68" alt="Portrait of Ada Lindqvist, research lead" width="440" height="550" loading="lazy" decoding="async"><b>Ada Lindqvist</b><span>Research · 7 yrs</span></li>
<li style="--i:3"><img src="https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?auto=format&fit=crop&w=440&q=68" alt="Portrait of Sam Herrera, delivery lead" width="440" height="550" loading="lazy" decoding="async"><b>Sam Herrera</b><span>Delivery · 6 yrs</span></li>
</ul>
</div>
<p class="why">Colour peaks as each face reaches the middle of the screen, so attention and saturation move together — and the names carry the information, never the colour.</p>
</div>
</section>
```
## CSS
```css
.rv-08 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 20px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-08 * {
box-sizing: border-box;
}
.rv-08__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
.rv-08__team {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(120px,1fr));
gap: 12px;
list-style: none;
margin: 60% 0 0;
padding: 0;
}
.rv-08__team li {
display: grid;
gap: 8px;
}
.rv-08 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4/5;
object-fit: cover;
border-radius: 12px;
}
.rv-08__team span {
font: 500 11px/1.3 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
.rv-08__note {
margin: 14px 0 30%;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── the bloom: COVER range, so colour peaks mid-viewport ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-08 img {
animation: rv08-colour linear both;
animation-timeline: view();
animation-range: cover calc(30% + var(--i) * 2%) cover calc(55% + var(--i) * 2%);
}
}
}
/* filter lists interpolate POSITIONALLY — same functions, same order, both keyframes */
@keyframes rv08-colour {
from {
filter: grayscale(1) brightness(.72) contrast(1.08);
}
to {
filter: grayscale(0) brightness(1) contrast(1);
}
}
/* pointer users get the same payoff; hover query keeps touch clean */
@media (hover: hover) {
.rv-08 img:hover {
filter: grayscale(0) brightness(1.03) contrast(1);
}
}
/* ── fallback: --c is distance from viewport centre, written below ── */
@supports not (animation-timeline: view()) {
.rv-08 img {
filter: grayscale(calc(1 - var(--c,0))) brightness(calc(.72 + .28 * var(--c,0))) contrast(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-08 img {
filter: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-08__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-08__uc h4 {
margin: 0;
font: 600 clamp(19px,2.6vw,30px)/1.14 system-ui,sans-serif;
letter-spacing: -.026em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-08__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-08__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-08__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-08__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-08__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-08__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-08__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-08__uc .col {
display: grid;
gap: 6px;
}
.rv-08__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-08__uc {
margin: 0 0 110%;
}
.rv-08__uc .shell.pad {
padding: 22px;
}
.rv-08__uc .head {
display: flex;
gap: 16px;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rv-08__uc .where {
font: 400 12px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uc .roster {
grid-template-columns: repeat(auto-fit,minmax(125px,1fr));
gap: 14px;
margin: 0;
}
.rv-08__uc .roster li {
gap: 10px;
}
.rv-08__uc .roster b {
font: 600 13.5px/1.3 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .roster span {
font: 400 11.5px/1.3 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
```
## JavaScript
```js
/* OPTIONAL — progressive enhancement only. Reproduces the cover-range behaviour by writing a
0-1 value based on distance from the scroller's vertical centre. */
if (!CSS.supports('animation-timeline: view()')) {
const scroller = document.querySelector('.rv-08');
const imgs = [...scroller.querySelectorAll('img')];
let queued = false;
const update = () => {
queued = false;
const box = scroller.getBoundingClientRect();
const mid = box.top + box.height / 2;
for (const img of imgs) {
const r = img.getBoundingClientRect();
const dist = Math.abs((r.top + r.height / 2) - mid) / (box.height / 2);
img.style.setProperty('--c', (1 - Math.min(1, dist)).toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
scroller.addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}
```Here's a working CSS Image Reveal on Scroll from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Grayscale-to-Color Contrast Reveal
Source: https://codefronts.com/motion/css-image-reveal-scroll/grayscale-to-color-contrast-reveal/
Team bios and client logo walls that bloom into colour as they reach the middle of the viewport. The demo that teaches the cover range — colour peaks mid-screen rather than on arrival — plus the filter-list interpolation rule that breaks most hand-written filter animations, and hover parity so pointer users get the same payoff.
## HTML
```html
<section class="rv-08" tabindex="0" role="region" aria-label="Grayscale to colour reveal demo">
<p class="rv-08__hint">scroll inside — colour peaks mid-viewport <span aria-hidden="true">↓</span></p>
<ul class="rv-08__team">
<li style="--i:0">
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Maya, design lead" width="560" height="700" loading="lazy" decoding="async">
<span>Maya · design</span>
</li>
<li style="--i:1">
<img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Tom, engineering lead" width="560" height="700" loading="lazy" decoding="async">
<span>Tom · engineering</span>
</li>
<li style="--i:2">
<img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=560&q=70"
alt="Portrait of Ada, research lead" width="560" height="700" loading="lazy" decoding="async">
<span>Ada · research</span>
</li>
</ul>
<p class="rv-08__note">range: cover 30% → 55% · not entry</p>
<div class="rv-08__uc">
<p class="rv-08__uclabel"><b>real-world use case</b> about page · team & leadership</p>
<div class="shell pad">
<div class="head">
<div class="col"><p class="eyebrow">The team</p><h4>Fourteen people. No account managers.</h4></div>
<span class="where">Bristol · remote-first</span>
</div>
<ul class="rv-08__team roster">
<li style="--i:0"><img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=440&q=68" alt="Portrait of Maya Okonjo, design lead" width="440" height="550" loading="lazy" decoding="async"><b>Maya Okonjo</b><span>Design lead · 9 yrs</span></li>
<li style="--i:1"><img src="https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=440&q=68" alt="Portrait of Tom Reyes, engineering lead" width="440" height="550" loading="lazy" decoding="async"><b>Tom Reyes</b><span>Engineering · 12 yrs</span></li>
<li style="--i:2"><img src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=440&q=68" alt="Portrait of Ada Lindqvist, research lead" width="440" height="550" loading="lazy" decoding="async"><b>Ada Lindqvist</b><span>Research · 7 yrs</span></li>
<li style="--i:3"><img src="https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?auto=format&fit=crop&w=440&q=68" alt="Portrait of Sam Herrera, delivery lead" width="440" height="550" loading="lazy" decoding="async"><b>Sam Herrera</b><span>Delivery · 6 yrs</span></li>
</ul>
</div>
<p class="why">Colour peaks as each face reaches the middle of the screen, so attention and saturation move together — and the names carry the information, never the colour.</p>
</div>
</section>
```
## CSS
```css
.rv-08 {
--accent: oklch(0.88 0.19 118);
--stage: 100vh;
font-family: system-ui,'Segoe UI',sans-serif;
height: var(--stage);
overflow-y: auto;
overflow-x: hidden;
padding: 0 20px;
border-radius: 16px;
border: 1px solid oklch(1 0 0/.1);
background: linear-gradient(oklch(0.16 0.01 260),oklch(0.19 0.012 260));
}
.rv-08 * {
box-sizing: border-box;
}
.rv-08__hint {
position: sticky;
top: 150px;
margin: 0;
height: 0;
text-align: center;
font: 400 11.5px/1 ui-monospace,monospace;
letter-spacing: .06em;
color: oklch(1 0 0/.45);
}
.rv-08__team {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(120px,1fr));
gap: 12px;
list-style: none;
margin: 60% 0 0;
padding: 0;
}
.rv-08__team li {
display: grid;
gap: 8px;
}
.rv-08 img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4/5;
object-fit: cover;
border-radius: 12px;
}
.rv-08__team span {
font: 500 11px/1.3 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
.rv-08__note {
margin: 14px 0 30%;
font: 500 13px/1.5 ui-monospace,monospace;
color: oklch(1 0 0/.5);
}
/* ── the bloom: COVER range, so colour peaks mid-viewport ────── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.rv-08 img {
animation: rv08-colour linear both;
animation-timeline: view();
animation-range: cover calc(30% + var(--i) * 2%) cover calc(55% + var(--i) * 2%);
}
}
}
/* filter lists interpolate POSITIONALLY — same functions, same order, both keyframes */
@keyframes rv08-colour {
from {
filter: grayscale(1) brightness(.72) contrast(1.08);
}
to {
filter: grayscale(0) brightness(1) contrast(1);
}
}
/* pointer users get the same payoff; hover query keeps touch clean */
@media (hover: hover) {
.rv-08 img:hover {
filter: grayscale(0) brightness(1.03) contrast(1);
}
}
/* ── fallback: --c is distance from viewport centre, written below ── */
@supports not (animation-timeline: view()) {
.rv-08 img {
filter: grayscale(calc(1 - var(--c,0))) brightness(calc(.72 + .28 * var(--c,0))) contrast(1);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-08 img {
filter: none;
}
}
/* ── real-world use case block ────────────────────────────────── */
.rv-08__uclabel {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font: 400 11.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uclabel b {
font: 600 10.5px/1 ui-monospace,monospace;
letter-spacing: .07em;
text-transform: uppercase;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 6px 8px;
border-radius: 6px;
}
.rv-08__uc h4 {
margin: 0;
font: 600 clamp(19px,2.6vw,30px)/1.14 system-ui,sans-serif;
letter-spacing: -.026em;
color: oklch(0.97 0 0);
text-wrap: balance;
}
.rv-08__uc h5 {
margin: 0;
font: 600 15.5px/1.28 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc p.eyebrow {
margin: 0;
font: 500 10.5px/1 ui-monospace,monospace;
letter-spacing: .11em;
text-transform: uppercase;
color: var(--accent);
}
.rv-08__uc p.body {
margin: 0;
font: 400 13px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.58);
}
.rv-08__uc .cta {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.rv-08__uc .btn {
font: 600 12.5px/1 system-ui,sans-serif;
color: oklch(0.16 0.01 260);
background: var(--accent);
padding: 12px 16px;
border-radius: 9px;
}
.rv-08__uc .btn.ghost {
color: oklch(0.94 0 0);
background: none;
border: 1px solid oklch(1 0 0/.2);
}
.rv-08__uc .price {
font: 600 16px/1 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .why {
margin: 12px 0 0;
font: 400 12.5px/1.6 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
.rv-08__uc .shell {
border: 1px solid oklch(1 0 0/.1);
border-radius: 20px;
overflow: clip;
background: oklch(0.18 0.01 260);
}
.rv-08__uc .col {
display: grid;
gap: 6px;
}
.rv-08__uc .meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font: 400 12.5px/1 ui-monospace,monospace;
color: oklch(1 0 0/.55);
}
.rv-08__uc {
margin: 0 0 110%;
}
.rv-08__uc .shell.pad {
padding: 22px;
}
.rv-08__uc .head {
display: flex;
gap: 16px;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rv-08__uc .where {
font: 400 12px/1 ui-monospace,monospace;
color: oklch(1 0 0/.45);
}
.rv-08__uc .roster {
grid-template-columns: repeat(auto-fit,minmax(125px,1fr));
gap: 14px;
margin: 0;
}
.rv-08__uc .roster li {
gap: 10px;
}
.rv-08__uc .roster b {
font: 600 13.5px/1.3 system-ui,sans-serif;
color: oklch(0.97 0 0);
}
.rv-08__uc .roster span {
font: 400 11.5px/1.3 system-ui,sans-serif;
color: oklch(1 0 0/.5);
}
```
## JavaScript
```js
/* OPTIONAL — progressive enhancement only. Reproduces the cover-range behaviour by writing a
0-1 value based on distance from the scroller's vertical centre. */
if (!CSS.supports('animation-timeline: view()')) {
const scroller = document.querySelector('.rv-08');
const imgs = [...scroller.querySelectorAll('img')];
let queued = false;
const update = () => {
queued = false;
const box = scroller.getBoundingClientRect();
const mid = box.top + box.height / 2;
for (const img of imgs) {
const r = img.getBoundingClientRect();
const dist = Math.abs((r.top + r.height / 2) - mid) / (box.height / 2);
img.style.setProperty('--c', (1 - Math.min(1, dist)).toFixed(3));
}
};
const onScroll = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
scroller.addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
}
```How this works
This is the one demo that should not use entry. An entry range completes the moment the element is fully in view — at the very bottom of the screen — which means the portraits would already be at full colour before the user is really looking at them. animation-range: cover 30% cover 55% instead uses the cover range, which spans the element's entire journey from first touching the scrollport to last leaving it. Mapping the animation to 30–55% of that journey puts the colour peak near the vertical middle of the screen, and animation-fill-mode: both holds it there afterwards. The effect is attention-following rather than arrival-triggered.
The reveal itself is a chained filter: grayscale(1) brightness(.72) contrast(1.08) → grayscale(0) brightness(1) contrast(1). Chaining three functions in one filter declaration is still a single composited pass, so it costs no more than one. But there is a strict rule: filter lists interpolate function by function, positionally. The same functions must appear in the same order in both keyframes. Write grayscale() brightness() in one and brightness() grayscale() in the other and the browser cannot interpolate — it snaps at 50% instead.
Finally, hover parity. On a desktop, a portrait that only colours on scroll feels broken when you point at it, so @media (hover: hover) adds a matching filter: grayscale(0) on hover — inside a hover query so touch devices, which have no real hover, never get a sticky stuck state. And because colour is doing communicative work here, the captions carry the actual information: nothing depends on the colour change alone.
Make it yours
- Shift the peak by moving the range:
cover 20% cover 45%colours earlier,cover 40% cover 65%later. - Client logo walls: end at
grayscale(.15)rather than 0 so brand colours stay restrained and consistent. - Add a matching
scale(1.02)for a gentle lift as colour arrives — one extra property, same timeline. - Reverse it (colour → grayscale on exit) for an archival, receding feel by animating over
cover 55%→cover 90%. - Wrap in
@containerand use a tighter range on narrow viewports, where the vertical middle arrives sooner.
Gotchas — read before shipping
- Filter functions interpolate positionally — the list must be in the SAME order in both keyframes, or the animation snaps at the midpoint instead of blending.
- Do not use
entryhere; it finishes at the bottom of the screen, so colour peaks before the user is looking at the image. - Put hover behind
@media (hover: hover), otherwise touch devices get a stuck hover state after a tap. - Never encode meaning in the colour change alone (WCAG 1.4.1) — the caption must carry the information.
filteron a large grid of images creates one composited layer per image; keep tile counts reasonable on mobile.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | 26+ | flag | 115+ |
filter: grayscale() is universal since 2015 and @media (hover: hover) since 2016. Only animation-timeline: view() with the cover range is new; the js fallback drives a --c custom property from distance-to-viewport-centre, which reproduces the effect exactly.