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.

Readable on a light image, because the scrim is underneath
<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.

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.

This paragraph is the part the reader cannot see, and the fade is what tells them it exists.

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

Grid lines, dots, stripes and hatch — no images involved
<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.

The text colour comes with the fill
g-brand
g-good
g-warn
g-bad
<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.

A gradient border, one class
<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.

22 keep 5 weak 8 demote
A verdict on every gradient class
ClassVerdictThe 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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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-text is the one to be careful with. Gradient text is background-clip: text with 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-hatch marking a block unavailable — needs a non-gradient signal as well.
  • Text on a gradient needs its contrast checked at the worst point. .g-brand and 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-block is 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-spin and the .g-sheen hover 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-motion is 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-mesh and 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-text on 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-spin looks like a loading indicator; .spinner is one, and carries the reduced-motion exemption that keeps it visible.