PRIMITIVE
Grade
Every filter CSS has, as a vocabulary you can animate. One class, a knob per filter, and a transition over all of them.
The whole vocabulary
Same class on all ten. Only the custom property differs, and every one of them transitions.










<!-- Every filter CSS has, one knob each, all of them animatable. The class is
the same on all nine; only the custom property differs. -->
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,130px),1fr)); gap:16px; width:100%">
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">nothing set</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-grayscale:1" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">grayscale 1</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-blur:5px" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">blur 5px</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-sepia:0.85" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">sepia 0.85</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-saturate:2.4" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">saturate 2.4</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-hue:140deg" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">hue-rotate 140deg</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-invert:1" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">invert 1</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-brightness:0.5" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">brightness 0.5</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-contrast:2" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">contrast 2</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:1/1"><img class="grade" style="--grade-shadow:0 0 14px oklch(0.7 0.2 20)" src="/scenery/sprig.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">a drop-shadow</figcaption>
</figure>
</div>A named look is also a dial
grade-mono does not set grayscale to 1. It sets it to the mix β so one property turns the whole look down, and it is the same property a directive writes from a scroll position.


--grade-mix: 0.5.
<!-- A named look is a look AND a dial. `grade-mono` does not set grayscale to
1, it sets it to the MIX β so one property turns the whole look down, and
it is the same property a directive writes from a scroll position. -->
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,160px),1fr)); gap:16px; width:100%">
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:4/3"><img class="grade grade-mono" src="/scenery/fronds.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">grade-mono, nothing else. The mix falls back to 1.</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:4/3"><img class="grade grade-mono" style="--grade-mix:0.5" src="/scenery/fronds.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">The same class at <code>--grade-mix: 0.5</code>.</figcaption>
</figure>
<figure style="margin:0; display:flex; flex-direction:column; gap:10px">
<div class="frame" style="--ratio:4/3"><img class="grade grade-mono" style="--grade-mix:0" src="/scenery/fronds.webp" alt="" loading="lazy" /></div>
<figcaption class="placeholder-caption">And at 0, which is the picture untouched.</figcaption>
</figure>
</div>Scroll this page
Each picture is grey while it is away from the line and arrives at its colour as it crosses it. Four of these drive the class above; the last two compose their own filters in the directive.
<!-- Scroll this page. Each picture is fully grey while it is away from the
line and arrives at its own colour as it crosses it.
`x-ds-grade` with no expression drives whatever named look the element
already wears β `grade-mono` here, which becomes a dial rather than an on
switch. The last two write their own combination instead, which is what you
want when it is particular to one page and does not deserve a class.
`data-ds-line` is where on the viewport a picture is fully developed, 0
being the top edge and 1 the bottom. `data-ds-span` is how far away, in
viewport heights, the grade reaches full strength.
With no script at all every one of these is simply in colour: `grade.css`
sets `--grade-mix: 0` on anything carrying the directive, so the picture
is undeveloped rather than frozen grey.
THE LAST ONE GRADES THE FRAME RATHER THAN THE PICTURE, so its mat and its
hairline develop too and the whole tile arrives together. That is a
deliberate choice and not a mistake: see the note about containing blocks
for the one question that decides which element to put the class on. -->
<div class="mount-wall" style="--mount-min:150px">
<div class="frame" style="--ratio:1/1">
<img class="grade grade-mono" x-ds-grade src="/scenery/marble.webp" alt="White marble with grey veining" loading="lazy" />
</div>
<div class="frame" style="--ratio:1/1">
<img class="grade grade-mono" x-ds-grade src="/scenery/ink.webp" alt="Coral ink opening in water" loading="lazy" />
</div>
<div class="frame" style="--ratio:1/1">
<img class="grade grade-mono" x-ds-grade data-ds-span="0.25" src="/scenery/curl.webp" alt="Curled peach paper" loading="lazy" />
</div>
<div class="frame" style="--ratio:1/1">
<img class="grade grade-mono" x-ds-grade data-ds-line="0.25" src="/scenery/silk.webp" alt="Folded silk" loading="lazy" />
</div>
<div class="frame" style="--ratio:1/1">
<img class="grade" x-ds-grade="grayscale 1; blur 5px" src="/scenery/fronds.webp" alt="Fern fronds" loading="lazy" />
</div>
<div class="frame" style="--ratio:1/1">
<img class="grade" x-ds-grade="saturate 0; brightness 0.55; blur 3px" src="/scenery/storm.webp" alt="A storm over water" loading="lazy" />
</div>
<div class="frame grade grade-mono" style="--ratio:1/1" x-ds-grade>
<img src="/scenery/linen.webp" alt="Woven linen" loading="lazy" />
</div>
</div>Reference
Read from primitives/grade.css when this page was built, so it cannot fall behind the stylesheet.
.grade.grade-mono--grade-speed β falls back to --ds-grade-speed--grade-ease β falls back to --ds-grade-easeNotes
This is the question worth answering before you use either. A static class and a scroll-driven effect look like two different things that happen to be about filters, and building them as two is how you end up with a directive that reimplements the vocabulary in JavaScript.
They meet at one custom property. --grade-mix is how much of the look applies, every named look multiplies by it, and it falls back to 1 β so a class on its own is simply on. The directive writes that one property and nothing else. It never learns what grade-mono is made of, and the stylesheet never learns that a scroll exists.
Which is why the same directive can also compose its own filters. Give x-ds-grade an expression and it writes the amounts as calc() declarations that read the mix, once, at startup. There is no third mechanism in there β just the same property, written from a different place.
One property per element per scroll frame. The amounts are calc(0px + var(--grade-mix, 0) * 6px), written once, so the browser does arithmetic it was going to do anyway. Writing nine final values every frame was the obvious spelling and it is nine times the work for the same pixels.
filter on an element establishes a containing block for position: fixed and absolute descendants, and a stacking context besides β and it does that whether the chain draws anything or not. So .grade on a wrapper holding a fixed overlay pins the overlay to the wrapper instead of to the viewport, and no value of any knob here fixes it.
That is a reason to look inside the box, though, and not a rule to always grade the picture. This page said "put the class on the picture, not on the box around it" for half a day, and that turned a real trap into advice that was simply wrong half the time.
One question decides it. Does anything in this box position itself against something further out? A frame with a picture in it does not β grade the frame and its mat and its hairline develop with the picture, which is usually what you wanted. A frame wearing frame-link does, and so does any box holding a popover or a fixed overlay. The last tile in the specimen above grades its frame; the others grade the <img>.
Measured on a dark theme before trying it: a frame's mat carries a chroma of 13 out of 255 and its hairline 8. That reads as far too little for grayscale to do anything with, and the prediction was that grading the box would look identical to grading the picture. Photographed side by side it is obvious β a warm plum mat going neutral grey against a warm band is a change you see at once. Small numbers, and the eye is what settles it.
A shorter chain assembled per look would mean a rule per combination, and the whole point is that a caller composes their own. The cost is compositing rather than arithmetic: the browser rasterises the element once and filters the result, which it would do for one function too.
It applies inside the chain, so a later drop-shadow sees the faded pixels and casts a faded shadow. Reach for the property when you just want the element fainter; reach for this when the fade has to happen before something else in the chain looks at it.
.grade transitions filter, so a hover or a class change crossfades. Against a value that changes every scroll frame the same transition is a first-order lag: the picture trails the scroll by its own duration, and on a fast flick it stays grey for a beat after it should be in colour. The scroll is the timeline, so x-ds-grade writes --grade-speed: 0s on what it drives. That is the whole reason the speed is a knob.
A look asked for in markup should be on, which is why the mix falls back to 1. A look asked for from a scroll position is the opposite: with no script there is no scroll position, so grade.css sets --grade-mix: 0 on anything carrying the directive. Alpine's inline write beats that rule, so the enhancement takes over with nothing to undo.
A wall of drawings stuck in permanent black and white for anybody with scripting off is the failure that rule prevents, and the colour of a drawing is the thing it is.
A grade that follows a scroll is a continuous change tied to a gesture, which is what somebody asking for less motion is asking to be spared. The directive parks at the undeveloped end β full colour, nothing hidden β and stops listening. The preference is read on every pass rather than once, because it can change under a running page.