Gradients
Thirty-four .g-* classes, every one built from the brand hue and
interpolated in oklab or oklch — which is what avoids the
grey dead zone you get when sRGB blends two saturated colours. Change
--hue-brand and all of them retune together.
When to use them
The useful ones fall into three groups, and they are worth separating because they are not the same kind of thing at all:
- Gradients doing a job — a scrim making text legible over a photo, a fade telling the reader content continues past an edge, a pattern standing in for a background image. These earn their place.
- Gradients as a surface — a brand-filled panel that also sets the right text colour. The gradient is incidental; the paired text colour is the point.
- Gradients as decoration — a sweep, a shine, a slow drift. Pleasant, and the part of this file that does not survive being asked what it is for.
That third group is the subject of the audit below, which is the most useful thing on this page.
The ones doing a job
Scrims
.g-scrim puts a legible floor under text laid over an image. It is
eased rather than flat — a 50% black wash dulls the whole picture, while this only
darkens where the text actually sits. The stops now live in the
--scrim token, shared with
.carousel-caption.
<figure class="g-scrim" style="position:relative;border-radius:var(--r-md);overflow:clip;max-inline-size:26rem">
<img alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22300%22%3E%3Crect width=%22640%22 height=%22300%22 fill=%22%23c9d4cf%22/%3E%3C/svg%3E">
<figcaption style="position:absolute;inset-block-end:0;inset-inline:0;padding:var(--space-4);color:oklch(98% 0 0)">
Readable on a light image, because the scrim is underneath
</figcaption>
</figure>
<figure class="g-scrim" style="position:relative;border-radius:var(--r-md);overflow:clip;max-inline-size:26rem">
<img alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22300%22%3E%3Crect width=%22640%22 height=%22300%22 fill=%22%23c9d4cf%22/%3E%3C/svg%3E">
<figcaption style="position:absolute;inset-block-end:0;inset-inline:0;padding:var(--space-4);color:oklch(98% 0 0)">
Readable on a light image, because the scrim is underneath
</figcaption>
</figure>
Fade masks
Content that runs off an edge should fade rather than be cut with a hard line — the
fade is what tells a reader there is more. .g-fade-inline fades both
inline edges, .g-fade-end only the end, and
.g-fade-block the bottom of a truncated block.
<div class="g-fade-block" style="max-block-size:7rem;overflow:hidden;max-inline-size:34rem">
<p>A fade at the bottom of a truncated block says the text continues. A hard cut says the text ended, which is a different claim and usually a false one.</p>
<p>This paragraph is the part the reader cannot see, and the fade is what tells them it exists.</p>
</div>
<div class="g-fade-block" style="max-block-size:7rem;overflow:hidden;max-inline-size:34rem">
<p>A fade at the bottom of a truncated block says the text continues. A hard cut says the text ended, which is a different claim and usually a false one.</p>
<p>This paragraph is the part the reader cannot see, and the fade is what tells them it exists.</p>
</div>
A mask is visual only. The hidden text is still in the DOM, still read by a screen reader, still found by a page search — so a fade must never be the only thing standing between the reader and content they need.
Patterns
The source calls these "gradients standing in for an image", which is exactly right:
they cost nothing to download and they retint with the theme.
--cell sets the spacing on the two repeating ones.
<div class="grid grid-tight">
<div class="g-grid-lines" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-dots" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-stripes" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-hatch" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
</div>
<div class="grid grid-tight">
<div class="g-grid-lines" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-dots" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-stripes" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
<div class="g-hatch" style="block-size:6rem;border:1px solid var(--line);border-radius:var(--r-md)"></div>
</div>
.g-hatch is tinted with the warn colour and is the one with a specific
job: an unavailable block in a schedule. .g-stripes is neutral, for a
zone that is merely inactive.
Surfaces
The point of these is not the gradient — it is that each also sets the correct text
colour. .g-brand applies --text-on-brand, and the status
three apply --text-on-good and friends, all of which are computed
against the fill rather than hard-coded.
<div class="grid grid-tight">
<div class="g-brand" style="padding:var(--space-4);border-radius:var(--r-md)">g-brand</div>
<div class="g-good" style="padding:var(--space-4);border-radius:var(--r-md)">g-good</div>
<div class="g-warn" style="padding:var(--space-4);border-radius:var(--r-md)">g-warn</div>
<div class="g-bad" style="padding:var(--space-4);border-radius:var(--r-md)">g-bad</div>
</div>
<div class="grid grid-tight">
<div class="g-brand" style="padding:var(--space-4);border-radius:var(--r-md)">g-brand</div>
<div class="g-good" style="padding:var(--space-4);border-radius:var(--r-md)">g-good</div>
<div class="g-warn" style="padding:var(--space-4);border-radius:var(--r-md)">g-warn</div>
<div class="g-bad" style="padding:var(--space-4);border-radius:var(--r-md)">g-bad</div>
</div>
Gradient borders
.g-border uses the two-background trick: a flat fill clipped to
padding-box over a gradient clipped to border-box, with a
transparent border letting the second show through. It is awkward enough to write
from memory that having it as a class is justified on its own.
<div class="g-border" style="padding:var(--space-4);border-radius:var(--r-md);max-inline-size:22rem">
A gradient border, one class
</div>
<div class="g-border" style="padding:var(--space-4);border-radius:var(--r-md);max-inline-size:22rem">
A gradient border, one class
</div>
Mesh
.g-mesh is three soft radial blooms — no image, no SVG filter, and
deliberately no blur(), because blurring a large area is expensive and
overlapping radial gradients read the same.
<div class="g-mesh" style="block-size:9rem;border-radius:var(--r-md);border:1px solid var(--line)"></div>
<div class="g-mesh" style="block-size:9rem;border-radius:var(--r-md);border:1px solid var(--line)"></div>
The audit
The brief for this page was to justify all thirty-four one at a time, and to say so if some could not be. Some cannot. Here is the whole file with a verdict on each, worked out by trying to write a case a reader would care about and noticing when I could not.
| Class | Verdict | The case, or the absence of one |
|---|---|---|
.g-scrim |
keep | Text over a photo. Eased rather than a flat wash, so it darkens only where the text sits. Shared with .carousel-caption through the --scrim token. |
.g-scrim-top |
keep | The same from the block start, for a caption above an image. |
.g-fade-inline |
keep | Fades content at both inline edges instead of cutting it. This is what a .scroller should have at its edges. |
.g-fade-end |
keep | Fades at the end edge only — a row that continues off-screen in one direction. |
.g-fade-block |
keep | Fades the bottom of a truncated article, under a "read more". |
.g-grid-lines |
keep | A grid pattern with a --cell size. A gradient standing in for an image: nothing to download, and it retints with --line. |
.g-dots |
keep | A dot pattern, same idea, same argument. |
.g-hatch |
keep | Diagonal hatching in the warn colour, for an unavailable block in a schedule. A genuine job with no other component for it. |
.g-stripes |
keep | Neutral stripes, for a zone that is inactive rather than unavailable. |
.g-hairline |
keep | A glowing one-pixel edge that fades at both ends — a nav or modal top edge. Used nowhere yet, which is a gap in the demo rather than a fault in the class. |
.g-border |
keep | A gradient border, via the padding-box/border-box trick. Genuinely awkward to write from memory, which is a good reason for it to be a class. |
.g-brand |
keep | A brand-filled panel with --text-on-brand applied, so the text contrast is handled. |
.g-brand-soft |
keep | The tinted version, for a highlighted card. |
.g-dark |
keep | A dark panel — a terminal block, a dark hero band. |
.g-surface |
keep | A quiet wash behind a section. Designed not to fight content. |
.g-sunken |
keep | The inverse, for a footer or a well. |
.g-good |
keep | Status fill with --text-on-good. The computed text colour is the reason to have it rather than write a gradient inline. |
.g-warn |
keep | As above. |
.g-bad |
keep | As above. |
.g-mesh |
keep | Three soft radial blooms for a marketing hero. No image, no SVG filter, no blur — blur over a large area is expensive and this reads the same. |
.g-text |
keep | Gradient headline text via background-clip. Needs care — see Accessibility — but it is a real, common request. |
.chart-area-g |
keep | The gradient fill under an area chart line. Documented on the charts page. |
.g-mesh-subtle |
weak | A second copy of the mesh with lower alpha and one fewer bloom. Defensible — the full mesh is too strong behind text — but it is a variant written out longhand rather than a modifier. A --mesh-alpha would cover both. |
.g-fade-more |
weak | A stronger .g-fade-block. A variant of a variant; the mask distance could be a custom property. |
.g-border-soft |
weak | A softer .g-border. Same objection. |
.g-ring |
weak | A conic ring with a mask. Plausible as a progress ring or an avatar ring, but nothing in Deck uses it that way and .donut already draws a ring from a value. |
.g-conic |
weak | A full-hue conic wheel. I cannot name what it is for. A colour picker, maybe — Deck does not ship one. |
.g-mesh-warm |
demote | Sets --hue-brand: 34 and nothing else. It is not a gradient; it is one arbitrary hue with a name. style="--hue-brand:34" does the same thing and says what it does. Used nowhere. |
.g-shimmer |
demote | Runs the same dk-shimmer keyframes as .skeleton, at 1.6s instead of 1.4s. A second name for a loading shimmer that .skeleton already provides. |
.g-bar-fill |
demote | A vertical gradient on --series. .chart-bar-fill and .progress already fill bars; this is a third way to do it. |
.g-text-shine |
demote | An animated sweep across gradient text. Decoration with no stated job, on top of text that is already hard to make accessible. |
.g-sheen |
demote | A highlight that sweeps on hover. Decoration. Pleasant, and it belongs in a page's own stylesheet rather than in a frozen framework API. |
.g-border-spin |
demote | A rotating gradient border. Decoration. |
.g-ring-spin |
demote | A spinning conic ring at 1.6s. Reads as a loading indicator, which .spinner already is. |
.g-mesh-drift |
demote | Drifts the mesh over 22 seconds. Reduced-motion guarded, so it is not harmful — but "the background moves slowly" is not a job, and a hero that never holds still is a cost the reader pays for nothing. |
What I would do
Demote the eight. They are not broken and they are not ugly — they are decoration, duplicates, or a preset for one arbitrary value. What makes them a problem is that they are in a frozen public API: every one is a name Deck has promised to keep, and a name someone has to read past to find the classes that do a job.
.g-mesh-warm is the clearest case and worth stating on its own. It
contains one declaration — --hue-brand: 34. It is not a gradient at
all; it is a named preset for a single hue, and
style="--hue-brand:34" does the same thing while saying what it does.
Nothing uses it.
.g-shimmer and .g-bar-fill are the other unambiguous ones:
both are second implementations of something Deck already has —
.g-shimmer literally runs .skeleton's own
dk-shimmer keyframes, at 1.6s instead of 1.4s.
Fold the five weak ones into properties.
.g-mesh-subtle, .g-fade-more and
.g-border-soft are each a copy of their sibling with different numbers.
A custom property on the base class — --mesh-alpha,
--fade-distance, --g-width — replaces all three and matches
how the rest of Deck is built. .g-border already has
--g-width; the pattern exists and was simply not applied here.
That would take the file from thirty-four public classes to twenty-one, with nothing
lost that a page could not do for itself in three lines of its own CSS. It is
recorded in FINDINGS.md and deliberately not acted on here — this pass
documents, and removing eight frozen classes is a decision rather than a
documentation task.
Classes
Generated from src/20-gradients.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.g-surface |
deck.effects |
Quiet. These sit behind content and must never fight it. | 20-gradients.css:13 |
.g-sunken |
deck.effects |
Sets background-image. | 20-gradients.css:18 |
.g-brand |
deck.effects |
Sets background-image, color. | 20-gradients.css:23 |
.g-brand-soft |
deck.effects |
Sets background-image. | 20-gradients.css:28 |
.g-dark |
deck.effects |
Sets background-image, color. | 20-gradients.css:32 |
.g-mesh |
deck.effects |
Three soft radial blooms. No image, no SVG filter, no blur — blur on a large area is expensive and this reads the same. | 20-gradients.css:41 |
.g-mesh-drift |
deck.effects |
Sets animation. | 20-gradients.css:66 |
.g-mesh-subtle |
deck.effects |
Sets background-image, background-size, background-repeat. | 20-gradients.css:56 |
.g-mesh-warm |
deck.effects |
Sets --hue-brand. | 20-gradients.css:53 |
.g-text |
deck.effects |
background-clip on the text. Always keep a real color underneath so the text is still readable if the clip fails. | 20-gradients.css:78 |
.g-text-shine |
deck.effects |
Sets color, background-image, background-size, -webkit-background-clip, and 2 more. | 20-gradients.css:88 |
.g-border |
deck.effects |
Two backgrounds with different clip boxes. Works with any border-radius, unlike border-image, and leaves the inside a real surface color. | 20-gradients.css:106 |
.g-border-soft |
deck.effects |
Sets background. | 20-gradients.css:114 |
.g-border-spin |
deck.effects |
Sets animation. | 20-gradients.css:123 |
.g-hairline |
deck.effects |
A single glowing hairline along the top edge — good for a nav or a modal | 20-gradients.css:128 |
.g-scrim |
deck.effects |
A caption over a photo needs a legible floor. A flat 50% black overlay dulls the whole image; an eased scrim only darkens where the text sits. | 20-gradients.css:145 |
.g-scrim-top |
deck.effects |
No declarations. | 20-gradients.css:157 |
.g-fade-inline |
deck.effects |
Content that runs off the edge should fade, not be cut with a hard line. | 20-gradients.css:166 |
.g-fade-end |
deck.effects |
Sets mask-image, -webkit-mask-image. | 20-gradients.css:172 |
.g-fade-block |
deck.effects |
Sets mask-image, -webkit-mask-image. | 20-gradients.css:176 |
.g-fade-more |
deck.effects |
Long text that is collapsed: fade the bottom instead of a hard cut | 20-gradients.css:181 |
.g-sheen |
deck.effects |
A highlight that sweeps across on hover. --sheen is registered, so it animates as a real percentage instead of jumping. | 20-gradients.css:198 |
.g-good |
deck.effects |
Sets background-image, color. | 20-gradients.css:219 |
.g-warn |
deck.effects |
Sets background-image, color. | 20-gradients.css:220 |
.g-bad |
deck.effects |
Sets background-image, color. | 20-gradients.css:221 |
.g-conic |
deck.effects |
Sets background-image. | 20-gradients.css:224 |
.g-ring |
deck.effects |
Sets border-radius, background, mask, -webkit-mask. | 20-gradients.css:228 |
.g-ring-spin |
deck.effects |
Sets animation. | 20-gradients.css:237 |
.g-grid-lines |
deck.effects |
Not gradients as decoration — gradients standing in for an image. These cost nothing to download and retint automatically. | 20-gradients.css:245 |
.g-dots |
deck.effects |
Sets background-image, background-size. | 20-gradients.css:251 |
.g-stripes |
deck.effects |
Sets background-image. | 20-gradients.css:255 |
.g-hatch |
deck.effects |
Hatching for a "pending" or "unavailable" block in a schedule | 20-gradients.css:261 |
.g-shimmer |
deck.effects |
Sets background-image, background-size, background-repeat. | 20-gradients.css:268 |
.g-bar-fill |
deck.effects |
Sets background-image. | 20-gradients.css:287 |
Tokens
--hue-brand is the one that matters: every gradient in the file is
computed from it, so the whole set retunes when it changes.
| Token | Live | Declared value |
|---|---|---|
--hue-brand |
196 |
|
--scrim |
linear-gradient(to top, oklch(12% .02 var(--hue-neutral) / .82) 0%, oklch(12% .02 var(--hue-neutral) / .62) 22%, oklch(12% .02 var(--hue-neutral) / .28) 48%, transparent 78%) |
|
--brand-400 |
oklch(70.2% calc(var(--chroma-brand) * .90) var(--hue-brand)) |
|
--brand-500 |
oklch(60.8% var(--chroma-brand) var(--hue-brand)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--text-on-brand |
oklch(99% .01 var(--hue-brand)) |
|
--good-600 |
oklch(52% var(--chroma-status) var(--hue-good)) |
|
--warn-500 |
oklch(76% var(--chroma-status) var(--hue-warn)) |
|
--bad-600 |
oklch(52% calc(var(--chroma-status) * 1.25) var(--hue-bad)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--bg |
light-dark(var(--ink-50), var(--ink-1000)) |
|
--dur-5 |
620ms |
Accessibility
-
.g-textis the one to be careful with. Gradient text isbackground-clip: textwith a transparent fill, so its contrast varies along its own length — one end may pass and the other fail. Use it on large display text only, never on body copy, and check the lightest point rather than the average. -
Windows High Contrast removes background images entirely. Every
class here is a
background-image, so in forced-colours mode they all disappear. Anything carrying meaning —.g-hatchmarking a block unavailable — needs a non-gradient signal as well. -
Text on a gradient needs its contrast checked at the worst point.
.g-brandand the status three set a computed text colour, which handles the common case; a gradient you write yourself does not. -
Fade masks hide content visually only. The text behind a
.g-fade-blockis still announced and still searchable. That is usually what you want, and it means the fade is not a substitute for truncation. -
The animated ones are all reduced-motion guarded.
.g-mesh-drift,.g-text-shine,.g-border-spin,.g-ring-spinand the.g-sheenhover are each declared inside@media (prefers-reduced-motion: no-preference), so they are never declared at all rather than declared and cancelled. That is the right construction, and it is consistent across the file. -
A drifting background is still motion for everyone else. Being
guarded does not make ambient animation free — it competes with the content for
attention, and
prefers-reduced-motionis a setting most people have never found.
Right to left
Most of these are symmetrical or angle-based and need nothing.
.g-fade-inline and .g-fade-end are the exceptions worth
knowing about: they are built from mask-image with a direction, and a
mask has no logical form — so .g-fade-end fades the physical end of the
box rather than the writing direction's end.
.g-hairline's gradient runs 90deg and is symmetrical about
its centre, so it looks identical either way.
Reduced motion
Five classes animate, and every one is inside
@media (prefers-reduced-motion: no-preference) rather than relying on the
global reset. That means a reader who has asked for less motion never receives the
declaration at all — the cleanest form of the guard, and worth copying.
.g-border-spin and .g-ring-spin animate a registered
--g-angle property, which is why the rotation interpolates smoothly
rather than jumping. Registering a property is what makes a custom property
animatable at all.
Printing
None of these are in src/99-print.css, and browsers drop background
images when printing unless told otherwise — so every gradient on this page vanishes
on paper. For decoration that is correct and saves ink.
For .g-hatch it is a defect: a schedule block marked unavailable by
hatching prints as an ordinary empty block, with the meaning silently removed. Same
class of problem as the chart fills, which are in the
print-color-adjust: exact list. Recorded in FINDINGS.md.
Overriding it
/* Per-instance, no layer needed */
<div class="g-grid-lines" style="--cell:32px">
<div class="g-border" style="--g-width:3px;--g-angle:90deg">
@layer app.components {
/* The subtle mesh as a property rather than a second class */
.g-mesh { --mesh-alpha: .30; }
.g-mesh-quiet { --mesh-alpha: .12; }
}
That last one does not work today — .g-mesh writes its alphas inline in
the gradient stops. Making it work is the change
recommended above.
When not to use them
-
Not behind body text.
.g-meshand the washes are designed to sit behind content, but a busy background under a paragraph costs legibility for nothing. Use.g-mesh-subtle, or nothing. -
Not for gradient body copy.
.g-texton anything smaller than a display heading is a contrast problem that varies along the line. - Not to carry meaning on their own. Forced-colours mode removes every one of them. A hatched block needs a label too.
- Not the decorative eight, in an application. A sweep, a shine and a drifting hero belong on a marketing page, and a marketing page can write them in three lines of its own CSS. See the audit.
-
Not as a substitute for a component.
.g-ring-spinlooks like a loading indicator;.spinneris one, and carries the reduced-motion exemption that keeps it visible.