Foundations · 06

Materials

The manual describes six layers that always stack in the same order. Keep the order and you get the brand's sense of space for free; mix them and you get mud.

The six layers

Back to front:

01

Base layer — grey to white

The ground everything stands on: the page-wide wash from CF-Grau to white. It carries the foil's own three hues, reversed, at OKLab chroma 0.005 — four levels of 255, under the grain already on it. Read as a colour it is neutral; that is the point. Nothing else on this layer is ever tinted.

.page-wash · --surface-page-wash

02

Opaque surfaces — black

Container objects, buttons, the footer. Rarely the background of a whole section — and when it is, as a deliberate cut.

.material-solid · --surface-inverse

03

Frosted glass

A background layer that calms complex graphics underneath. The navigation band is made of it, and the opaque logo and link plates sit on top of it — glass under opaque, never the other way round. It always needs something behind it, and something complex behind it, or it is just a grey box.

Its edge is one line: the lit rim, --glass-border. Glass is never outlined in black — not the material, and not the one control made of it. A dark ring was drawn inside the rim for a release and has been taken back off both, because whatever it was answering, what it looked like was a box with a border round it. What it was answering, and the two ways to answer it that are not a line, is A control's edge below.

.material-glass · --glass-blur · --glass-border

03b

…and the same glass, almost clear

Glass does three jobs and therefore has three tints. Bearing glass has text sitting directly on it over a backdrop nobody controls — the hero video — so its veil is a contrast floor, not a look: white 46 % is the point at which black type still clears 4.5:1 composited over a pure black backdrop, which makes the guarantee hold for every frame rather than for the frames someone checked. Veil glass carries nothing but opaque objects, so it stays at white 4 % and lets the blur be the whole effect.

The third is below, and it is the one that has to stand somewhere else.

.material-glass--veil · --surface-glass · --surface-glass-veil

03c

…and the same glass over a backdrop the page owns

This one is standing on the field, not on the poster, and that is the demo rather than a change of scenery. The 46 % floor above is derived from a premise — that anything at all could be behind the pane — and the premise is not universal: where the thing behind the glass is the system's own field, a lattice on the wash whose darkest pixel is a hairline, the floor is whatever the measurement on that surface says. --surface-glass-thin is white 30 %, measured on the Expertise stage, where the plate's darkest pixel is rgb(220, 221, 223) and the body copy reads 6.73:1. Re-measured off this plate, which stands on the same field higher up a lighter part of the wash: darkest pixel rgb(235, 235, 235), black label 17.62:1. The number moves with the backdrop, which is the whole reason it has to be taken on the backdrop.

The measurement is the permission, not the number. Reach for it only over a backdrop you can name, and re-measure the type on it; over anything arbitrary the 46 % floor is the one that holds. Put this plate over the poster the two samples above stand on and it is not a thinner pane, it is the floor's premise being ignored.

The class is new and the tint is not. It was declared, documented and reached for by patterns/expertise.html through --info-card-tint, and no page in the system drew it — so no screenshot of any fallback ever contained it, and it sat out the forced-colours block below for three releases while the other two tints went to Canvas. A tint nothing draws is a tint nothing checks. The fifth claim is the general fix; this plate is why it was missing.

.material-glass--thin · --surface-glass-thin

04

Contours

Frames, sketches, vector illustrations. The default way to bound an area — reach for it before any fill.

.material-outline · --stroke-1 · --border-strong

05

Light layer

Lime or the lime gradient, for highlights and temporary effects. Light falling on a surface — not a colour fill. Once per screen.

.material-light · --gradient-light · --glow-lime

.material-rake is the same layer with its one free parameter exposed: the ramp stated as a band from lime to CF-Grau, with Glas wherever the rake puts it. --mid and --far move the rake, --mirror turns the face away, --grazing takes the designer's own 239.25°. Use it when the rake is the point; .material-light when the designer's 132.36° signature is.

.material-bloom is the same ramp arriving out of a point rather than across a face — light on the rim with the surface at the centre, which is what four of the six gradients in the designer's dump do. --iso puts it on a 2:1 isometric plate; --top turns it round to light a surface from above. → Colour

06

Text and information layer

Copy, informative content, buttons, vector illustrations. Always in front, always black or white — never in an accent colour.

--text-primary · --text-secondary

Stacked

All six together — this is what the landing page hero is, technically.

Control-F
Menü

Find the answers.

Rules

Do

  • Keep the order: base → opaque → glass → contour → light → text.
  • Use frosted glass only where something complex actually sits behind it.
  • Put opaque objects on the glass; that is what the layer order means.
  • Use the bearing tint the moment text sits on glass, and the veil tint otherwise.
  • Contours before fills — a frame says enough.
  • Where a block does need a surface, take the veil: a proportion of the light already there, so the step survives the wash.
  • Use shadows sparingly; depth comes from overlap, not from blur.

Don't

  • No frosted glass on top of frosted glass.
  • No frosted glass over a flat surface — it has nothing to calm and becomes a grey box.
  • No backdrop-filter under an opaque background. The blur cannot be seen and still costs a GPU pass.
  • No lime under text — the light layer never sits behind body copy.
  • No opaque surface in an accent colour.
  • No gradients on large areas other than the base wash.
  • No absolute grey as a panel surface on the wash. Between CF-Grau and white every one of them inverts somewhere on the screen.

Working with glass

Where it is, and where it deliberately is not

Two surfaces in the whole system are frosted: the navigation band, and the hero button. They do overlap, for about 150 px of scroll — see Cost below, which this sentence used to contradict by claiming they never meet. Everything else that looks like a panel — the process card and its note block, the value-table cells, the blog-grid cells, the footer CTA — sits on the page wash with nothing complex behind it, so it is drawn with a contour instead. Frosted glass there would be an effect rather than a material.

That paragraph was true of five panels out of six and the sixth was the one it named first. The process card carried background: var(--surface-card) — an opaque near-white plate — with a second plate on its copy column and a grey block for its note, on a page that says contour before fill. The material agrees with the page rather than with the code: sampled down the Discovery plate in mockups/landing-page.jpg, the card's interior and the page margin beside it read 206.8 against 207.0, 208.4 against 208.0, 210.9 against 211.0 and 211.9 against 212.0 — within 0.2 at every row, and the only rows that differ at all are the ones the illustration's own light passes through. There is no plate under the figure. There is no longer one in the card.
That sampling went down the figure half, and the drawing's other half does not agree with it. The card is divided at x = 600 in the 1200 px plate by its own interior hairline. Left of it the numbers above hold. Right of it — the copy column, x 620…1120, over the card's full interior height y 2125…2655 — every sampled pixel reads a flat 248, 248, 248 while the page margin beside it climbs 219 → 226 across the same rows. That is +22 to +29, constant, and it cannot be the wash: the wash moves and this does not. The designer drew the process card as a contour figure panel on the page and an opaque light plate under the copy. The implementation draws neither, so half the card is faithful and half of it lost a material layer.

It is left standing this run rather than half-restored, because the paragraph below is the reason and it applies here too: a constant grey inside the wash's own range inverts as the reader scrolls, and the honest form of a step toward the light is a veil of white — which fades to nothing at the white end of the wash, exactly where the note block's veil is at its strongest. That is a value the materials family does not have yet and a designer's call to make, not a measurement's. What is settled is that the gap is real and the sentence above it was too broad. → --surface-sunken in tokens.css, and the note block's row in the table below.

The plate could not hold still either, which is the part that generalises. The wash is viewport-fixed, so it runs CF-Grau to white down every screen regardless of scroll position. Measured at 1280 × 900, the card's plate stood +37.6 off the page near the top of the viewport and +7.5 near the bottom — the same card separating from its own page five times as strongly at one scroll position as at another, and dissolving into it at the last. A contour is ink. It is the same line wherever the card happens to be.

Every panel, and what it is made of

The question each one has to answer is the manual's, not a preference: is there something complex behind this, which frosted glass would calm? Six times the answer is no, and the interesting part is that "no" does not mean "nothing" — it means contour, and a surface only where a block genuinely has to read as set back from the one it is in.

PanelWhat is behind itMade ofHow it responds
navigation bandthe whole page, movingveil glass + lit edgerim fades in over the first 5 rem, specular crosses the document
hero buttonthe hero videobearing glass + lit rimspecular on hover, focus, press and on its own passage up the viewport
info-card platethe isometric ground latticebearing glass + lit rimthe band crosses once as the card passes the reader; on the pinned Expertise stage, once across the whole pin
mobile menuthe hero videoopaque plate — see abovea clip edge travels down it
process card — figure halfthe page washcontournothing — it is not interactive, and a hover response on a panel that does not answer is a lie
  · its copy halfthe page washcontour — the plate is drawn and not implementednothing
  · its note blockthe card, which is now the wash--surface-sunken, a veilnothing
value-table cellsthe page washcontournothing
blog-grid cellsthe page washcontour--sheen-panel travels across on hover — these are links
accordion rowsthe page washcontour--sheen-panel travels across on hover — these open
footer CTASchwarzno panel at all — type on the dark surfacethe title is filled with the foil
docs demo framesthe page washcontour, documentation onlynothing — they ship nowhere

The split in that last column is the rule worth carrying forward, and it is why the process card gets no sheen even though it is the largest panel on the page. --sheen-panel is a response: light crossing a surface because a pointer arrived at something that will answer. The blog cell is a link and the accordion row opens. The process card, the value row and the note block do nothing when you click them, and lighting them on hover would promise otherwise.

Those panels still respond. --sheen-panel parks a light gradient in its own transparent half and slides it across on hover, so the row reads as lit rather than tinted. It is the light layer, not the glass layer, and it is never lime — the screen only gets one lime moment and the page has already spent it.

The three glass surfaces answer three different things, and now all three answer something. Read down that column and the pattern is a question about what the reader is doing to the surface, not about what the surface is: the navigation band is a plane the whole document moves under, so its light is scrubbed by the document; the hero button is the thing a pointer arrives at, so its light is scrubbed by the pointer. The info-card plate is neither — it is not a link, and on the pinned Expertise stage it is deliberately the one object that does not change while the copy on it does. What it can answer is the reader travelling past it, so its band is scrubbed by its own passage: view() for a card that scrolls by, and the stage's own timeline for the plate that is pinned, because a pinned element's view progress freezes at exactly the moment the reader starts moving through it. Until this run it was the only frosted surface in the system with a rim that never moved, which read as the material being switched off on the one panel a reader spends the longest looking at.

All three put the moving light on an unblurred one-pixel rim beside the sheet rather than on the sheet. That is not a stylistic agreement, it is the cost rule: moving a gradient across a backdrop-filter layer makes the browser re-rasterise the blur on every frame it runs, and a scroll-driven animation runs for the length of a document. scripts/check-glass-budget.py fails the build on it, so the agreement cannot quietly lapse.

One panel has the complex backdrop that would justify glass and is opaque anyway: the mobile navigation menu. It drops out of the bar directly over the hero video, which is exactly the condition the manual reserves frosted glass for — and it is still a plate, because it is the only surface on the site that bears text over a backdrop that changes 25 times a second. Bearing glass holds 4.5:1 against a worst-case frame; an opaque plate holds 16:1 against every frame, and on the primary control of the whole mobile page that margin is worth more than the material. Glass is for calming a graphic you still want to see. Nobody needs to see the video through the menu.

It is also the one panel in the system that opens, and how it opens follows from the same reasoning: a clip edge travels down it, so the plate is fully opaque in every frame of the motion. A fade would put the labels over a partly transparent plate for the length of the transition and hand the contrast floor back to whichever frame was playing. This is light only adds — the rule below — restated for a reveal: the guarantee never depends on how far the wipe has travelled, exactly as it never depends on where the highlight is.

A control's edge

Glass in this brand is not outlined, and that is now a ruling rather than a preference. A 1 px dark ring — black 55 %, one pixel inside the lit rim — ran round .material-glass and .cf-btn--glass for one release to carry a boundary the rim cannot hold. It came off the material first and the button second. This section is what is left: the measurement that motivated it, which is real, and the two ways to answer it that are not a line.

The rim alone does not give the hero CTA a boundary. Every layer on this material adds light, so a glass plate approaches its backdrop as its backdrop approaches white, and the whole perimeter goes with it. Measured on the hero CTA against the shipping artwork, worst pixel per edge, consent banner dismissed:

WidthTopBottomLeftRight (lime end)
12801.25:11.08:11.15:11.11:1
14401.31:11.07:11.14:11.11:1
19201.10:11.08:11.16:11.09:1

Every edge of the primary call to action on the site, at every desktop width, against a 3:1 requirement. The label was never in question and still is not — it measures 16.8:1, because the bearing tint guarantees it over any frame. What these numbers are about is the plate's perimeter, and only that.

Nothing painted against the artwork can be guaranteed, and this is the part worth carrying to the next component. The artwork is a video: black holds only where the backdrop is light, white only where it is dark, and a frame can be either. A darker rim could not have fixed it, a lighter one could not either, and no amount of sampling frames settles a case that is decided by the frames nobody sampled. That is the same wall the focus ring hit.

Which leaves exactly two honest answers, and neither of them is a line:

The button is currently the second one. That is a decision and it is recorded here rather than left to be discovered: the numbers in the table above are live again. Anyone who wants the first can have it, and it is a design change — a different material on the hero CTA — not a token coming back. Do not reintroduce the ring.

How glass responds to the reader

Glass gets a different treatment from a flat panel, because a sheet of glass has a specular. The hero button carries a soft band of light — --cf-specular, a registered custom property so it interpolates rather than switches — parked off the right edge at rest and travelling in across the plate on hover and on focus. It is a band and not a blob: the gradient's vertical radius is 300 % of the button, so almost no falloff happens across a 48 px plate and what crosses it is a column of light, which is what a flat sheet does with a grazing source. Refraction hinted at, never simulated.

And the same plate answers the scroll, because a pointer is not the only way a reader arrives at it. Until recently every response this button made was gated on :hover, :focus-visible or :active — none of which a touch device ever sends. The primary call to action of the whole site was therefore a static plate for every reader on a phone, on the one surface where the brand's north star, a foil that shifts as you move past it, had the best chance of being true. So the rim carries --glass-rim-light as well: the same band, the same 40 % width and the same two parking positions the navigation sheet uses, crossing once as the button travels up the screen.

Two lights on one control do not collide, because they are on the two layers this family already keeps apart. --cf-specular is on the face — the source reflected, achromatic, driven by the pointer. The band is on the edge, over --glass-edge, driven by the scroll. One lit edge with hue, one specular without: the division below is now literally true on both frosted surfaces rather than true on one and asserted on the other.

It is windowed to the rim's own 62 %, not to the plate, which is what keeps it off the lime. The band parks off the left of that window and leaves off its right, so the light dies at exactly the x where --glass-edge has already faded to nothing. Sampled across the plate as it crosses the viewport, the lime end (69–94 % across) does not move by a single level at any scroll position.

And it touches exactly one row, which is measured rather than reasoned from the geometry. Differencing the rendered plate with the band running against the same plate with it parked, at 1280 px: of 256 × 48 pixels, 54 differ by more than one level and every one of them is on row 0, max delta 13. Rows 1 to 47 — the whole label, the arrow, and every pixel of the bearing tint the type sits on — come back byte-identical at every scroll position sampled.

That is also why this layer needs no contrast table of its own, and the reason is the ruling above rather than an exemption. The plate makes no boundary claim against the artwork: glass is not outlined in this brand, so the rim is the material's edge and not a guaranteed 3:1 line, and adding white to a decorative rim cannot cost anything that was being counted. The floor that is counted is the label's, and it is set by background-color — beneath every image layer, and 24 rows away from a 1 px strip painted on the border box above the type. There was never a mechanism by which the band could reach it. The diff is here because it cannot is worth one measurement.

It is a pseudo-element, and that is the whole engineering of it. The plate carries backdrop-filter, and the rule under Cost below — never move anything on a blurred layer — applies to its own background as much as to the sheet's. The navigation already has the split it needs, blur on ::before and rim on ::after; this button paints blur, face and rim on one element, so the travelling band got an unblurred layer of its own and the plate's three background layers were left untouched. That is also what keeps every contrast figure measured on this component still valid — not one of them moved. Swept 3,000 px at 1280 px, three runs each way: 16.70 ms median and 16.8–16.9 ms p95 with the band and without it, indistinguishable.

A clipping ancestor silently freezes a view timeline. This animation did not work when it was first written, and nothing said so. A view timeline resolves against its subject's nearest scroll container, and overflow: hidden makes an element one — so the hero's crop, four levels up and there for a purely visual reason, became the timeline's source. Chromium reported a live ViewTimeline whose progress sat at 0.116 and did not move at any scroll position on the page: no error, no warning, and no visible difference from an animation nobody had written. The fix is overflow: clip, which crops without creating a scrollport, kept alongside hidden so an older browser still gets the crop. Audited across all six pattern pages before changing it — 40 scroll-driven animations, and this was the only one whose source was not the document. → Motion

This paragraph is now a check. The rule was stated here, and again on Motion, and a third time on the fix in components.css — three pieces of prose and nothing that ran any of them. scripts/check-overflow-clip.py holds every overflow: hidden in the three shipping stylesheets to carrying overflow: clip beside it, exempting only the blocks that truncate a line of text and the .visually-hidden idiom. It found the other half of the same bug in a prototype: an ambient wash cropped with overflow-x: hidden on <body>, which propagates to the viewport and leaves body computing to visible, so the crop never happened and the document sat 128 px scrollable sideways at 1280 with nothing out there to look at. clip is excluded from that propagation and stays on the box that declared it.

Light only adds — behind the label. Every layer between the bearing tint and the label lightens, so the darkest point the label ever sits on is the tint itself and the contrast floor is the same whether the highlight is present or not. The guarantee never depends on where the light happens to be.

The button ends at the same edge the sheet does. This page asserted that below — "one lit edge with hue, one specular without, on the sheet exactly as on the button" — for longer than it was true. --glass-edge was on the navigation rim and nowhere else; what the button had was a flat white 55 % border and a face gradient carrying Glas into lime, which is a lit face. The claim was the right design and the code has caught up to it: the token is now the button's top pixel too, painted on the border box under the rim colour, so both frosted surfaces in the system end at one edge drawn from one declaration.

The rim colour was a second copy of the same problem and is fixed the same way. --glass-border held white 55 % and was read by exactly one rule in the system — the .material-glass swatch on this page — while the only shipping glass surface with a border wrote the value out by hand. The two could not be changed together, and the inverse theme's redeclaration of the token reached the documentation swatch and never the button. The button reads the token now.

It is windowed, not rewritten, and that distinction is the rule worth carrying. --glass-edge is authored for a full-bleed sheet with no lime on it; this plate has lime at its right end, and running the edge's Sky tail into that end would put the material's chroma in two places at once — the same failure a Glas tail on the specular would cause. So the layer is sized to 62 % of the plate and the whole gradient renders inside that window: Weiss peaks at 9.9 % across, Glas at 33.5 %, Sky at 49.6 %, transparent again by 62 %. The face's lime becomes visible at about 63 %, measured — well before its declared 87 % stop — so the rim expires one point before the lime arrives, which is the relationship the specular already has. Size the layer and leave the stops alone; editing stop positions to fit a narrower box forks the family into two edges that merely resemble each other.

The rim costs no contrast, and it is worth saying why rather than asserting it, because the rim is the one layer on this plate that is not strictly additive — which is why the rule above now reads "behind the label" where it used to read "on the button". Measured on the rendered plate at 1280 px: over a white backdrop the rim's Sky stop is rgb(232,243,249) against a plate of rgb(235,248,245) — relative luminance 0.881 against 0.914, so the rim sits 3.6 % darker than what it crosses. Over a black backdrop the same point reads rgb(202,213,219) against rgb(144,157,154) and is far lighter. That is a real edge taking the colour of the light rather than always brightening, and it is the behaviour to want. It costs nothing because it is one pixel at the top of a 48 px plate, above the label rather than behind it, and the plate's floor is its background-color, which sits beneath all three image layers and covers the whole box either way. Sampled at the label's own row, every value is byte-identical to what the button rendered before the rim existed.

One mechanical consequence: background-repeat: no-repeat is now load-bearing on that rule. Every layer used to size to auto and fill the box, so the initial repeat had nothing to do; a 1 px layer under it would tile the rim down all 48 px of the plate.

That rule is about the type: what it protects is the darkest point under the label, which is why the label's 4.5:1 holds wherever the highlight happens to be. It was worth saying out loud while a dark ring sat at the padding edge, because the two looked like a contradiction and were not — one is a promise about legibility, the other was ink far from anything anybody reads. The ring is gone; the distinction is kept, because the next thing anyone adds near this edge will raise it again.

The navigation sheet gets a specular too, and it is driven by the scroll rather than by the pointer. It used to be stated as the sheet having no specular at all. A highlight travelling across the full width of the viewport on hover would be a screen effect, and the bar is not a thing you point at — it is a thing you scroll past. So the sheet answers the only gesture it actually receives: --glass-rim-light, a band 40 % of the bar wide, parked off the left edge and crossing the rim once over the length of the document. The reader moving down the page is the reader moving past the material, which is the one thing holographic stock does that a photograph of it cannot.

Read that as being about the gesture a surface actually receives, not as a partition between the two surfaces, because the hero button receives both and now answers both. The rule that generalises is: give a surface the light for every way a reader can arrive at it, and put each light on the layer that belongs to it — the pointer on the face, the passage on the edge. What would be wrong is the sheet answering a hover it never gets, or the plate answering only a hover half its readers cannot send.

Two things about it are load-bearing. It is on the rim, not on the sheet — see Cost below; and it is achromatic, because --glass-edge already carries this material's hue travel and a specular is the source reflected rather than the surface coloured. One lit edge with hue, one specular without, on the sheet exactly as on the button. It is also additive: parked off-canvas at rest, so a browser with no scroll timeline and a reader who has asked for reduced motion both get --glass-edge drawn exactly as it is designed, with nothing subtracted.

The rim carries a second, separate animation that is not a specular and should not be confused with one: --glass-edge fades in over the first 5 rem of scroll. That is a state change — the bar ceasing to be part of the hero and becoming a plane over the page — where the specular is not a state at all. Different thing, different range, same one pixel.

Animate the property, not the paint. A gradient background-image does not interpolate: given two gradients differing in a single number, the browser jumps to the new one on the first frame. So a highlight that is drawn as a gradient can only be moved by animating a registered custom property that the gradient reads — which is what @property is for, and why naming background in the transition would animate nothing.

Scope that narrowly. The rule is "a transition whose only moving part is a gradient animates nothing" — not "background in a transition list animates nothing". background-color interpolates perfectly well, and the base .cf-btn rule depends on it: measured mid-transition, --outline runs rgba(0,0,0,0)rgba(0,0,0,0.88) → black and --solid runs rgb(27,32,34)rgb(3,4,4) → black. --primary is the mixed case: its gradient snaps on frame one while the colour fades in underneath. Stripping background from that base rule on the strength of the paragraph above would silently kill two real transitions and half of a third.

Always declare that property's rest value on the component, as a plain declaration. This is the part worth carrying to the next component, because the failure mode is not the one you would guess and neither is the fix. Leaving the rest value to @property's initial-value alone means that in a browser without @property the variable is unset — and an unresolvable var() is guaranteed-invalid, which makes the entire background shorthand invalid at computed-value time. Every layer goes, not just the highlight: measured with the registration stripped, the button computed to background-image: none over a transparent background-color. backdrop-filter is a separate declaration and survives, so what was left was a blur with no plate — on the hero, a pure-black label directly over moving video with no bearing tint holding the contrast floor. A missing highlight would be cosmetic; a missing declaration is not.

A var(--cf-specular, 150%) fallback would resolve the value too, and in a correct browser it should be indistinguishable — a registered property with an initial-value is never guaranteed-invalid, so its fallback is never consulted and the two forms are equivalent. Use the plain declaration because it states the guarantee where the declaration is, rather than leaving it implied inside a var() argument that a reader may take for decoration and remove.

A Chromium 141 repaint quirk was cited here as a second reason and has been demoted, because it does not reproduce the way this page tells you to test. Driven by pointer hover, the fallback form does not repaint while the property interpolates — the plate holds its rest paint and snaps at the end, measured at identical property values rather than identical times. Driven by element.focus(), both forms travel identically. A reviewing lane could not reproduce it at all, for exactly that reason. Recorded here so nobody re-derives it, and deliberately load-bearing on nothing: if it were left as an argument, the first person to check it correctly would find nothing and delete the sound reasoning next to it.

Two traps worth remembering on their own, because between them they rule out both obvious ways of checking this by script. getComputedStyle cannot see the invalidation failure — the property reads as interpolating in both forms, to three decimal places, so an animation verified only by sampling computed values looks correct while painting nothing. And driving the property through element.style.setProperty() does not repaint at all in Chromium 141: sixteen columns sampled at 150 % and at 40 % come back byte-identical, while a real :hover repaints correctly. That is a wider invalidation footprint than the fallback case above, and it means the natural way to script this test returns a false negative on a form that actually works. Drive it through the real selector — a :focus-visible via element.focus() is the cleanest handle, since it needs no pointer and cannot scroll the page — and read pixels, not values.

One catch to carry with the rule: the rest value now exists twice — as initial-value in the @property block and as the declaration on the component — and nothing but a comment holds the two equal. They must move together. initial-value has to be a literal, so it cannot read the token, which means there is no way to make the coupling structural; the next component using this technique will inherit the same seam.

A plain declaration is the one option that gets both. Registered it is simply the from-value and interpolation is unaffected; unregistered it keeps the var() resolvable, so the plate survives and the highlight switches instead of travelling. The state change still reads and the contrast floor is untouched.

Reduced motion needs no handling here, but only by inheritance: --cf-specular is transitioned over --duration-slow, which tokens.css collapses to 1 ms under prefers-reduced-motion: reduce. The next component animating a registered property inherits that protection only if it also reaches for a duration token rather than writing a literal.

Cost

backdrop-filter is the most expensive thing in this stylesheet. The rules that keep it affordable: never add a third blurred layer; keep the blur radius at 16 px, since more reads no better; use position: sticky rather than fixed, because a fixed blurred element repaints its whole region every scroll frame in iOS Safari; and run nothing open-ended on a blurred layer, because moving its opacity or a gradient across it re-rasterises the blur on every frame it runs. That last rule is why both of the navigation's animations live on its one-pixel rim rather than on its sheet: an unblurred gradient is free, and a sheet that wants to respond to the reader can always do it through the edge it ends at. A sheet may reach past its own edge by --glass-lookahead so content arrives through the blur rather than popping into it, but no further — everything past one blur kernel is spend with nothing to show.

It used to read never move anything, and the shipping system has never obeyed that. .cf-btn--glass paints its blur, its face and its rim on one element, and --cf-specular travels a gradient across that face on every hover, focus and press. The rule as written called the site's primary call to action a violation of itself, which is the kind of sentence that gets a sound piece of design deleted by whoever reads it next.

The line is not whether a thing moves but how long it runs and what it runs against. A transition is bounded, user-initiated and over: the specular's is --duration-slow, one plate of 256 × 48, and the reader is holding still while it plays. A scroll-driven animation is neither — it runs for the length of the document, and every frame of it lands while the compositor is already re-reading the backdrop underneath. Measured on the landing page at 1280 px, driving the specular through :focus-visible over 58 frames, three runs each way: the blurred plate gives a 16.70 ms median and a 16.9–18.2 ms p95, and the same plate with its backdrop-filter removed gives 16.70 ms and 16.9–17.0 ms. Indistinguishable, which is what makes the exception real rather than tolerated.

So the rule that pays, and the one scripts/check-glass-budget.py actually enforces, is: no animation and no animation-timeline on a rule that declares backdrop-filter. Transitions are left alone deliberately. Stating it that way also makes it countable in a file, where never move anything was only ever countable by argument — and it is still what put .cf-nav::after and .cf-btn--glass::before on layers of their own, because both of those are scroll-scrubbed and neither would have been allowed to stay.

The two blurred layers do overlap, and the system used to claim they never do. The hero CTA is on the first screen and the bar is sticky, so the button passes under it on the way out: measured at 1280 × 900, they intersect from scrollY 628 to 776 and the button sits entirely inside the sheet from 672 to 732 — about 150 px of scroll carrying two stacked backdrop-filters. It costs nothing measurable; sweeps through the overlap window and through clear page give the same 16.7 ms median and overlapping p95 ranges. The rule worth keeping is therefore the one that pays — two blurred layers composited at once on a shipping page, never a third — not the stronger one the comment used to assert. What the checker counts is elements on a page, which is the conservative reading of that and the right default, because on every page but one every blurred layer is live whenever the page is; the exception is below. The scope matters, and this page is why: it carries four composited plates of its own, the three tint samples above and the band in the layer stack below. A documentation page showing a material has to be allowed more of it than a page selling something. Four rather than three since the thin tint got a plate — all four are 8 rem boxes in a column, they never overlap, and only the one nearest the viewport is composited while the reader is anywhere on the page. The cap that matters is on the pages that sell, and it has not moved.

The census is measured rather than remembered, and it used to be a sentence in this paragraph — landing page 2, Über uns 1, and 1 each on the component pages that demo it — which is exactly the count the next paragraph says is worth less than naming the spenders. scripts/check-glass-budget.py counts the blurred layers on every page in the tree and rewrites the table below, so the number here is generated the same way the space scale's is. Stamp 4fcd4f44 — a digest of the rows, so a reader can tell a current census from a stale one without reading it.

PageKindBlurred layers
components/buttons.htmldocumentation1
components/info-card.htmldocumentation3
components/navigation.htmldocumentation1
foundations/materials.htmldocumentation4
patterns/404.htmlshipping1
patterns/bewerbung-danke.htmlshipping1
patterns/bewerbung.htmlshipping1
patterns/blog-artikel.htmlshipping1
patterns/datenschutz.htmlshipping1
patterns/expertise.htmlshipping6
patterns/impressum.htmlshipping1
patterns/karriere-leer.htmlshipping1
patterns/karriere-stelle.htmlshipping1
patterns/karriere.htmlshipping1
patterns/kontakt-danke.htmlshipping1
patterns/kontakt.htmlshipping1
patterns/landing-page.htmlshipping3
patterns/news-thema.htmlshipping1
patterns/news.htmlshipping1
patterns/suche-leer.htmlshipping1
patterns/suche.htmlshipping1
patterns/ueber-uns.htmlshipping2

What the script enforces beyond the count is the other two rules on this page that are countable in a file: every backdrop-filter in the shipping CSS has to read var(--glass-blur) rather than state its own radius, and no rule that declares one may also carry an animation or an animation-timeline touching a property that repaints its backdrop. It also reads what counts as glass out of the stylesheet instead of holding a list, so a fourth frosted surface enters the budget by existing.

The fifth claim is about the fallbacks, and it is the one claim here that is not about cost. The three blocks in When there is no glass below work by redefining tokens rather than by giving every component its own branch, which buys a great deal and has exactly one failure mode: a tint left out of one block keeps its live value there, and whatever reads it keeps a material the block was written to take away. Nothing renders wrong. It renders as though the reader had never asked — and with the blur already gone, what comes through the surviving translucency is sharp, which is worse than either the material or a flat plate. So the script asserts that every block turning the material off turns all of it off, in every selector that block names, excusing only a token the block reads as its own answer. The family is a name shape rather than a roster, so a fourth tint is in scope the moment it is declared; the lit edge is deliberately outside it, because an edge is a contour and not a translucency — the paragraph below is what happens to it instead.

Where the third layer would come from, so it does not. The budget is spent; naming the likely spenders is worth more than a count somebody has to remember. The process-card panel and its note block, the value-table rows, the blog-grid cells and the footer CTA are the four surfaces that look like they want glass and must not have it — every one sits on the page wash with nothing complex behind it, where frosting has nothing to calm and becomes a grey box. If a genuine third case ever appears, something else has to give up its blur first.

One appeared, and it is the act rail's plate. The landing page reads 3 in the census above and that is not a budget somebody raised. The plate behind the open rail — .act-rail::before in assets/css/acts.css, an opaque --surface-raised panel until now — stands over 22 000 px of composition that carries its own labels, which is the material's definition at the top of this page rather than an exception to it. It was the grey box in reverse: the one surface on the page with something to calm, drawn flat because a count said so.

What pays for it is that the plate and the hero CTA cannot be lit at the same moment, and the reason is in the stylesheet rather than in this sentence. The plate paints only on :hover / :focus-within; the rail catches neither without pointer-events, which it only has while act-rail.js has set .is-live — and that is true only while the acts own the viewport, some 4 000 px below the button. The sticky nav band is the only layer the plate is ever composited with. Two at a time, three on the page, so the count the rule is actually about has not moved. That argument lives as a named page allowance in PAGE_BUDGET in scripts/check-glass-budget.py: no other page inherits it, an entry has to name two layers the CSS keeps apart, and an allowance whose page drops back under the default is itself a finding — a permission that outlives its argument gets read as headroom by whoever comes next.

A second one appeared, and unlike the first it is not free. Expertise reads 6 in the census above. Five of those six are the plate and the four copy cards, and the CSS does keep them apart: inside the pinned gate the plate is the material and .ex-step .cf-info-card sets backdrop-filter: none on every card, so that tier still spends the bar and the plate and nothing else. Below the gate there is no plate — it is one cell of a two-column grid that only exists up there — and the four cards stand on the same full-bleed lattice the plate was put there to calm, so each one carries the material itself. That much is the material's own rule: a card standing on something the reader can still see through it.

What it costs is a third composited layer at every seam, and the number is the point. The four cards are one column, so two of them are on screen together whenever the reader is between steps: measured at 375 × 812, a copy card is about 650 px tall and 349 px of figure and gap separate one from the next, which puts two cards and the sticky bar together for 463 px of each roughly 1 050 px step — about 44 % of that section's scroll, on a phone. The act rail's allowance was bought by a measurement showing no cost; this one is a design ruling, and the cheaper answer it was weighed against is real and one declaration away: --surface-glass-solid on the same cards is the material's own no-blur stand-in, and it reads as a flat grey slab where the frosted card keeps the wash's own gradient running through it. That is what was being bought.

Two smaller decisions on that plate, both of them the rule above rather than taste. It takes the 46 % bearing tint and not the thin one: the thin tint's permission is a measurement on one named backdrop, and this rail crosses five different drawings, so the floor for arbitrary artwork is the one that holds. And it carries no travelling light: the other three glass surfaces each end in a lit rim with a band crossing it, and a band scrubbed by scroll here would re-rasterise the blur through the exact scroll range the rail exists to be open during. It gets --glass-border and nothing moves on it.

What the measurement then changed about the type, which was not the plan. A translucent plate makes the row's backdrop the artwork, and this page's artwork reaches near-black. Swept at 250 px steps across the whole act range with the rail open, at 1024, 1440 and 1920 px, the darkest pixel the plate composites to is rgb(164,165,165) — act 3's display heading, blurred, crossing behind the rail at 1440. On that stop --text-primary reads 8.50:1, --text-secondary 3.70:1 and --text-muted 1.28:1. At 1920 nothing dark passes the rail at all and secondary would have read 6.70:1, which is exactly the kind of width-shaped near miss a single screenshot certifies as fine. The rail ran on two ink tiers, muted rising to secondary; the second tier does not hold, and secondary only ever held on this material over a backdrop somebody could name — which is the 6.73:1 .cf-info-card__count is measured at, on the one stage that card ships on. So the five titles take black, and the recession moves to the three aria-hidden marks in each row — the tick, the numeral and the glyph — where .cf-pin__index's own exemption already covers a ghosted counter. Which act you are on is still said twice, by the tick and by that row's marks. The 46 % floor is derived in tokens.css for black type over a backdrop nobody controls; asking it to carry a second tier over unnameable artwork was asking for a guarantee it never made.

The checker's own scope was the real finding. acts.css ships — patterns/landing-page.html loads it — and it was not in the script's list of shipping stylesheets. A backdrop-filter written there would have landed on the page carrying the tightest budget in the system and been invisible to all five claims: uncounted, free to state its own radius, free to be animated. The script's header is careful that what counts as glass is derived rather than listed, and one list further out was hand-maintained anyway. It is fixed, and it is the part of this change that was not about a single surface.

What those four get instead is not one answer, and this section used to say it was. The paragraph above ended "They get --sheen-panel instead" — of all four. Exactly one of them does. --sheen-panel is on the blog-grid cells, and on the accordion rows, which that sentence did not name at all. It is on nothing else in the system. The process panel and its note block, the value-table rows and the footer CTA carry no sheen, and should not.

The rule the list was standing in for: sheen is a pointer response, not a surface treatment. It is parked in its own transparent half and slid across on :hover and :focus-visible, so it exists only as an answer to someone arriving at the element. A blog cell and an accordion row are a link and a control; light crossing them is feedback. The three that go without are static content — a copy panel with its inset note, a value row, a footer block. A hover sheen there would promise an affordance that is not present, which is a worse failure than the grey box the paragraph above guards against, because it is legible and still wrong. They get the 1 px contour and the page wash, which is the manual's order — contour before fill — and that is a complete answer, not an omission.

The glass button is light contexts only

.cf-btn--glass must not be placed inside a data-theme="inverse" scope. Its label is --accent-ink, which is black, while the inverse theme flips --surface-glass to black 56 % — black type on a dark plate, near 1:1. Nothing reaches that today, since the button appears only on the landing page and its own documentation page and neither is inverse, but it is a real constraint rather than an oversight.

It cannot be fixed by flipping the label colour with the theme, which is the obvious guard. The button's right-hand end is lime in both themes, and lime demands black type; the plate under the left-hand end would demand white. One colour cannot serve both, so an inverse glass button is a redesign — a different lit edge, or no lime — not a token swap. Until someone draws one, this component belongs on light ground.

When there is no glass

There are three of these, not two, and they are handled once in tokens.css by redefining the tokens rather than by giving every component its own escape hatch. Where backdrop-filter is unsupported, the blur becomes none and the veil thickens to white 72 % so the band still separates. Where the reader has asked for prefers-reduced-transparency, every glass surface becomes the opaque --surface-glass-solid plate. Where forced-colors is active, every glass surface becomes Canvas and the blur goes off. The edge light survives all three — but only because it is redrawn in the third, and that is the correction this section exists to record.

This page used to say forced colours needed nothing from us. The sentence was “the platform decides the edge anyway … the one context where none of the arithmetic above applies”, and it is true of the button, which has a real border and gets it back in the reader's own link colour. It is not true of the material, and the sentence is the reason nobody looked. Forced colours computes every gradient background-image to none, and both of this system's lit edges are gradient background-images on a 1 px box with no border. Measured in Chromium on the landing page at 1280 px, before the fix: .cf-nav::after resolved to none, none over a transparent background — the navigation bar had no bottom edge at all, on every page, for every reader in that mode. That is the same loss print had until the screen and scope was added to the scroll block, arriving a second time through a different door.

Three things were wrong and each is fixed where it belongs:

Canvas rather than --surface-glass-solid, and the distinction is why this is a third block instead of one more line in the second. --surface-glass-solid is --grey-100, an author grey; in a dark high-contrast theme an author grey is the one thing the reader turned the mode on to stop seeing. The other two fallbacks answer the browser cannot and the reader wants less. This one answers the reader has chosen the palette, so it answers in the palette — which is also why its :root and [data-theme="inverse"] declarations are identical rather than merely parallel. Canvas and CanvasText already are the theme.

And it covered two tints out of three. --surface-glass-thin was left out of this block for as long as the block has existed, while both fallbacks above it cover all three. The tint is not spare: patterns/expertise.html sets --info-card-tint to it, so the pinned stage's lectern was the one glass surface in the system that stayed translucent under forced colours. Measured in Chromium with the mode active at 1280 px, .cf-info-card--glass computed background-color rgba(255, 255, 255, 0.30) with backdrop-filter already none — thinner than the 46 % the paragraph above is about, and with the blur off the section behind it came through sharp rather than calmed. An author grey over Canvas with the page legible through it, which is exactly what this block's own argument says the mode exists to stop.

Why it survived is the part worth keeping: the thin tint was the one tint this page could describe and not draw. There was no .material-glass--thin, so no page rendered it, so no screenshot of any fallback ever contained it. There is one now — 03c — and the fifth claim closes the general case, on both selectors of all three blocks, so the next tint cannot sit one of them out.

What the mode ends up with is the answer this page argues for everywhere else: the navigation is an opaque plate with a hairline under it, and the hero CTA is an opaque plate with a system-coloured border. Both of the two honest boundaries from A control's edge, chosen for us by a reader who has already said which one they want. Nothing here is a contrast measurement, and that is the point — CanvasText on Canvas is guaranteed by the platform rather than by us. The specular and the rim pass do not survive, and should not: both move a background-position that no longer has an image to move.