Skip to content

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.

nothing set
grayscale 1
blur 5px
sepia 0.85
saturate 2.4
hue-rotate 140deg
invert 1
brightness 0.5
contrast 2
a drop-shadow
HTML
<!-- 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-mono, nothing else. The mix falls back to 1.
The same class at --grade-mix: 0.5.
And at 0, which is the picture untouched.
HTML
<!-- 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.

White marble with grey veining
Coral ink opening in water
Curled peach paper
Folded silk
Fern fronds
A storm over water
Woven linen
HTML
<!-- 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.

CLASSES.grade.grade-mono
SETTINGSSet the first on one element; a theme sets the second for all of them.--grade-speed β€” falls back to --ds-grade-speed--grade-ease β€” falls back to --ds-grade-ease
READSNothing from the theme β€” it is arrangement only.

Notes

THE CLASSES AND THE DIRECTIVE ARE ONE FEATURE, NOT TWO

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.

WHAT IT WRITES PER FRAME, AND WHAT IT DOES NOT

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.

πŸ›‘ A FILTER MAKES A CONTAINING BLOCK, EVEN AT IDENTITY

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>.

AND IT REACHES ON LESS COLOUR THAN THE NUMBERS SUGGEST

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.

THE CHAIN IS ALWAYS TEN FUNCTIONS LONG

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.

opacity() HERE IS NOT THE opacity PROPERTY

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.

πŸ›‘ THE DIRECTIVE TURNS THE TRANSITION OFF

.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.

AND THE NO-SCRIPT STATE IS IN THE STYLESHEET

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.

DECLINED MOTION SETTLES, IT DOES NOT FREEZE

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.