@tenphi/glaze 0.0.0-snapshot.f3bb46b → 0.0.0-snapshot.f404191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ ---
2
+ title: Shadows and glows
3
+ description: Translucent overlays that land an exact tone step on any surface with room for it, in every scheme.
4
+ ---
5
+
6
+ import Example from '../docs-site/components/Example.astro';
7
+ import StepCompare from '../docs-site/components/shadows-and-glows/StepCompare.tsx';
8
+ import ObjectInfluence from '../docs-site/components/shadows-and-glows/ObjectInfluence.tsx';
9
+ import StateLayers from '../docs-site/components/shadows-and-glows/StateLayers.tsx';
10
+ import CardShadows from '../docs-site/components/shadows-and-glows/CardShadows.tsx';
11
+ import ElevationLadder from '../docs-site/components/shadows-and-glows/ElevationLadder.tsx';
12
+ import TintedPigment from '../docs-site/components/shadows-and-glows/TintedPigment.tsx';
13
+ import Unreachable from '../docs-site/components/shadows-and-glows/Unreachable.tsx';
14
+ import HdrGlows from '../docs-site/components/shadows-and-glows/HdrGlows.tsx';
15
+
16
+ export const stateTheme = {
17
+ hue: 280,
18
+ saturation: 80,
19
+ colors: {
20
+ surface: { tone: 100, saturation: 0.1 },
21
+ 'surface-hover': { type: 'shadow', bg: 'surface', intensity: 4 },
22
+ 'surface-pressed': { type: 'shadow', bg: 'surface', intensity: [8, 12] },
23
+ 'accent-surface': { tone: 52, mode: 'fixed' },
24
+ 'accent-surface-hover': { type: 'shadow', bg: 'accent-surface', intensity: 4 },
25
+ 'accent-surface-pressed': { type: 'shadow', bg: 'accent-surface', intensity: [8, 12] },
26
+ 'accent-surface-highlight': { type: 'glow', bg: 'accent-surface', intensity: 5 },
27
+ 'inverse-surface': { tone: 18, saturation: 0.2, mode: 'fixed' },
28
+ 'inverse-surface-hover': { type: 'glow', bg: 'inverse-surface', intensity: 4 },
29
+ 'inverse-surface-pressed': { type: 'glow', bg: 'inverse-surface', intensity: [8, 12] },
30
+ },
31
+ };
32
+
33
+ export const cardTheme = {
34
+ hue: 280,
35
+ saturation: 80,
36
+ colors: {
37
+ surface: { tone: 100, saturation: 0.1 },
38
+ card: { base: 'surface', tone: '-3', saturation: 0.15 },
39
+ 'shadow-sm': { type: 'shadow', bg: 'surface', fg: 'card', intensity: 3 },
40
+ 'shadow-md': { type: 'shadow', bg: 'surface', fg: 'card', intensity: [6, 9] },
41
+ 'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'card', intensity: [9, 14] },
42
+ },
43
+ };
44
+
45
+ export const elevationTheme = {
46
+ hue: 280,
47
+ saturation: 80,
48
+ colors: {
49
+ surface: { tone: 92, saturation: 0.1 },
50
+ 'elevation-1': { type: 'glow', bg: 'surface', intensity: 3 },
51
+ 'elevation-2': { type: 'glow', bg: 'surface', intensity: 5 },
52
+ 'elevation-3': { type: 'glow', bg: 'surface', intensity: 7 },
53
+ },
54
+ };
55
+
56
+ export const hdrTheme = {
57
+ hue: 280,
58
+ saturation: 80,
59
+ config: { glowTuning: { headroom: 1 } },
60
+ colors: {
61
+ surface: { tone: 100 },
62
+ 'surface-glow': { type: 'glow', bg: 'surface', intensity: 12 },
63
+ near: { tone: 95, saturation: 0, mode: 'static' },
64
+ 'near-glow': { type: 'glow', bg: 'near', intensity: 12 },
65
+ inverse: { tone: 12, saturation: 0, mode: 'static' },
66
+ 'inverse-glow': { type: 'glow', bg: 'inverse', intensity: 12 },
67
+ },
68
+ };
69
+
70
+ `shadow` and `glow` are translucent overlays. Each emits a pigment (black for a
71
+ shadow, white for a glow) with an opacity Glaze solves so that, once the
72
+ browser composites it over `bg`, the surface lands **exactly `intensity`
73
+ tones** darker (`shadow`) or lighter (`glow`), scaled by the object when you
74
+ name one. The step is the same on every surface and in every scheme where the
75
+ surface has room for it: light, mid, dark, and chromatic.
76
+
77
+ ```ts
78
+ import { glaze } from '@tenphi/glaze';
79
+
80
+ const theme = glaze(280, 80);
81
+
82
+ theme.colors({
83
+ surface: { tone: 97 },
84
+ card: { tone: 100 },
85
+ accent: { tone: 45 },
86
+
87
+ // Object-aware drop shadow: `fg` is the element casting it.
88
+ 'card-shadow': {
89
+ type: 'shadow',
90
+ bg: 'surface',
91
+ fg: 'card',
92
+ intensity: 8,
93
+ },
94
+
95
+ // No `fg`: an exact shade, for state layers and scrims.
96
+ 'surface-hover': { type: 'shadow', bg: 'surface', intensity: 5 },
97
+ 'accent-pressed': { type: 'shadow', bg: 'accent', intensity: [8, 14] },
98
+
99
+ // The mirror: dark-mode elevation, edge highlights, lightening states.
100
+ 'accent-highlight': { type: 'glow', bg: 'accent', intensity: 4 },
101
+ });
102
+ ```
103
+
104
+ Resolving this sample logs two warnings. In dark high contrast the `auto`
105
+ surface sits at tone 3, so `card-shadow` and `surface-hover` can't darken it by
106
+ their full step: they pin to black and warn, even when you don't emit
107
+ high-contrast tokens. See [Unreachable steps](#unreachable-steps).
108
+
109
+ Every field is listed in
110
+ [Color definitions](./reference/color-definitions.md#shadowcolordef--glowcolordef);
111
+ this page explains how the overlays behave.
112
+
113
+ ## The same step on every surface
114
+
115
+ `intensity` is a step on tone, the WCAG-shaped axis Glaze uses everywhere
116
+ (see [Tone and OKHST](./okhst.mdx)). For neutrals a tone step is a fixed WCAG
117
+ ratio, so 6 tones is about 1.20:1 wherever it lands, and the same number reads
118
+ the same on any surface. APCA cannot express steps this small, so overlays
119
+ are always measured in tone.
120
+
121
+ A fixed opacity cannot do this. Below, each surface carries a `shadow` with
122
+ `intensity: 6` (top patch) and a black layer at a fixed 8% (bottom patch); the
123
+ big number is the tone step each one actually paints.
124
+
125
+ <StepCompare
126
+ kind="shadow"
127
+ tones={[100, 70, 50, 25, 12]}
128
+ accentTone={50}
129
+ intensity={6}
130
+ fixedAlpha={0.08}
131
+ caption="Neutral surfaces at the listed tones, plus a hue-280 accent at tone 50. Top: shadow, intensity 6. Bottom: black at a fixed 8%. Big numbers are the measured tone step of the composite against its surface."
132
+ />
133
+
134
+ The solved shadow lands 6.0 tones everywhere, at an opacity that climbs as the
135
+ surface darkens. The fixed layer darkens white by almost 6 tones and a tone-12
136
+ surface by little more than one. Glows mirror this:
137
+
138
+ <StepCompare
139
+ kind="glow"
140
+ tones={[70, 50, 25, 12, 0]}
141
+ accentTone={50}
142
+ intensity={6}
143
+ fixedAlpha={0.08}
144
+ caption="Top: glow, intensity 6. Bottom: white at a fixed 8%. The fixed layer lifts every surface by a different amount; the solved glow lifts each one 6 tones."
145
+ />
146
+
147
+ ## How the opacity is solved
148
+
149
+ Browsers composite `source-over` on gamma-encoded sRGB: 50% black over white
150
+ paints `#808080`, not the `#bcbcbc` that mixing linear light would give. Glaze
151
+ models exactly that. The composite's luminance changes monotonically with
152
+ opacity, so a bisection finds the opacity that lands the step, on chromatic
153
+ surfaces too.
154
+
155
+ The required opacity depends heavily on the surface. A 6-tone shadow needs
156
+ α 0.081 on white, 0.107 on a tone-50 surface, and 0.477 on a tone-10 surface.
157
+
158
+ There is no opacity cap: when the step is reachable, it is exact. For a
159
+ lighter overlay, lower `intensity`.
160
+
161
+ The output is the pigment with its solved alpha. Shadows and glows appear in
162
+ every output method (`tokens()`, `tasty()`, `css()`, `json()`, and the rest)
163
+ alongside regular colors. The default pigment is neutral, so the values stay
164
+ clean:
165
+
166
+ ```text
167
+ rgb(0 0 0 / 0.0814) shadow, 6 tones on white
168
+ oklch(1 0 0 / 0.0953) glow, 6 tones on a tone-50 surface
169
+ ```
170
+
171
+ The alpha is only meaningful over the surface it was solved for. Paint the
172
+ token on top of `bg` (as a `box-shadow` color, a state-layer background, or a
173
+ pseudo-element over the surface).
174
+
175
+ ## Direction is fixed per type
176
+
177
+ A `shadow` darkens in light **and** dark schemes; a `glow` lightens in both.
178
+ Neither flips when the scheme does. Pick the type for the effect you want in
179
+ each scheme: for example, `shadow` for light-mode cards and `glow` for
180
+ dark-mode elevation. When one component needs a darkening layer in light mode
181
+ and a lightening one in dark mode, emit both tokens and wire each in the
182
+ scheme it belongs to. Both still resolve in all four schemes, so each one warns
183
+ wherever its surface has no room, including schemes where you don't use it
184
+ ([Unreachable steps](#unreachable-steps)).
185
+
186
+ `bg` and `fg` are read per scheme, so the solved opacity adapts on its own as
187
+ the surface moves in dark and high contrast. See
188
+ [Light, dark, and high contrast](./schemes.mdx) for how surfaces move between
189
+ schemes.
190
+
191
+ ## Object influence
192
+
193
+ `fg` is the object casting the shadow or emitting the glow, such as the card.
194
+ It scales the step the way bounce light does. With `fg`, the step is
195
+ `intensity × k`:
196
+
197
+ ```text
198
+ k = max(0, 1 + β · d · (t_bg − t_fg) / 100)
199
+ ```
200
+
201
+ `t_bg` and `t_fg` are the tones of the surface and the object, `d` is `+1` for
202
+ a `shadow` and `−1` for a `glow`, and `β` is `tuning.objectInfluence` (`0.5`
203
+ by default).
204
+
205
+ - An object at the surface's own tone gives exactly `intensity`, and so does
206
+ omitting `fg`.
207
+ - A darker object deepens its shadow; a lighter one softens it.
208
+ - For a glow it is the other way round: a lighter object glows stronger.
209
+ - `k` never drops below 0 and tops out at `1 + β` (a black object casting a
210
+ shadow on a white surface). `objectInfluence: 0` removes `fg`'s effect on
211
+ the step (`k = 1`); `fg` still feeds [`hueBleed`](#tinted-pigment).
212
+
213
+ Each card below casts a hard-edged `shadow` (offset, no blur) or emits a solid
214
+ `glow` ring, both with `intensity: 8` and `fg` pointed at the card:
215
+
216
+ <ObjectInfluence
217
+ intensity={8}
218
+ scenes={[
219
+ {
220
+ tone: 97,
221
+ label: 'Light surface · tone 97',
222
+ kinds: ['shadow'],
223
+ objects: [
224
+ { label: 'white card', tone: 100 },
225
+ { label: 'accent', tone: 45, chromatic: true },
226
+ { label: 'near-black', tone: 8 },
227
+ ],
228
+ },
229
+ {
230
+ tone: 12,
231
+ label: 'Dark surface · tone 12',
232
+ kinds: ['shadow', 'glow'],
233
+ objects: [
234
+ { label: 'raised card', tone: 20 },
235
+ { label: 'accent', tone: 55, chromatic: true },
236
+ ],
237
+ },
238
+ ]}
239
+ caption="Each label gives the object's tone, its factor k, and the step the composite lands (intensity 8, default β)."
240
+ />
241
+
242
+ The same numbers as a table:
243
+
244
+ | Surface | Object | `k` (`shadow`) | `shadow` step | `glow` step |
245
+ | --- | --- | --- | --- | --- |
246
+ | light 97 | white card 100 | 0.985 | 7.9 | — |
247
+ | light 97 | accent 45 | 1.26 | 10.1 | — |
248
+ | light 97 | near-black 8 | 1.445 | 11.6 | — |
249
+ | dark 12 | raised card 20 | 0.96 | 7.7 | 8.3 |
250
+ | dark 12 | accent 55 | 0.785 | 6.3 | 9.7 |
251
+
252
+ Point `fg` at the element that casts the shadow, never at text: `fg` is the
253
+ object, not a contrast reference. Like `bg`, it must not be another shadow or
254
+ glow.
255
+
256
+ ## State layers
257
+
258
+ Without `fg`, a shadow or glow is an exact shade or tint of its surface: the
259
+ right tool for hover and pressed states, which need the same visible change on
260
+ every fill.
261
+
262
+ <Example {...stateTheme} visual="none" />
263
+
264
+ <StateLayers
265
+ {...stateTheme}
266
+ rows={[
267
+ {
268
+ fill: 'surface',
269
+ label: 'Neutral fill',
270
+ layers: [
271
+ { name: 'surface-hover', label: 'Hover' },
272
+ { name: 'surface-pressed', label: 'Pressed' },
273
+ ],
274
+ },
275
+ {
276
+ fill: 'accent-surface',
277
+ label: 'Accent fill',
278
+ layers: [
279
+ { name: 'accent-surface-hover', label: 'Hover' },
280
+ { name: 'accent-surface-pressed', label: 'Pressed' },
281
+ { name: 'accent-surface-highlight', label: 'Highlight' },
282
+ ],
283
+ },
284
+ {
285
+ fill: 'inverse-surface',
286
+ label: 'Dark fill',
287
+ layers: [
288
+ { name: 'inverse-surface-hover', label: 'Hover' },
289
+ { name: 'inverse-surface-pressed', label: 'Pressed' },
290
+ ],
291
+ },
292
+ ]}
293
+ caption="Rest, hover, and pressed on three fills in your current scheme. Each number is the measured step of the layer over its fill."
294
+ />
295
+
296
+ The accent fill uses `mode: 'fixed'`, so it stays a mid tone in every scheme
297
+ and its layers land the same steps everywhere. The dark fill uses glows, so
298
+ its states lighten it. The neutral `surface` follows the scheme: in dark high
299
+ contrast it becomes black, and a shadow has nothing left to darken, so
300
+ `surface-hover` and `surface-pressed` stay fully transparent and warn (the
301
+ figure shows what they reached; see [Unreachable steps](#unreachable-steps)).
302
+ A glow on this surface fails the other way: it warns in the light schemes,
303
+ where the surface is white. No overlay fits a surface that spans the whole
304
+ tone range. For a state that darkens in light schemes and lightens in dark
305
+ ones, use a regular color relative to the surface, such as
306
+ `{ base: 'surface', tone: '-4' }`, which flips with the scheme.
307
+
308
+ ## Card drop shadows
309
+
310
+ For elevation in light schemes, define one shadow ladder with `bg` on the page
311
+ and `fg` on the card. Equal intensity steps read as evenly spaced on every
312
+ surface. Use [high-contrast pairs](#high-contrast) for shadows that should
313
+ deepen in high contrast.
314
+
315
+ <Example {...cardTheme} visual="none" />
316
+
317
+ <CardShadows
318
+ {...cardTheme}
319
+ page="surface"
320
+ card="card"
321
+ cards={[
322
+ { shadow: 'shadow-sm', geometry: '0 0.0625rem 0.25rem 0' },
323
+ { shadow: 'shadow-md', geometry: '0 0.375rem 1rem -0.125rem' },
324
+ { shadow: 'shadow-lg', geometry: '0 1rem 2.5rem -0.5rem' },
325
+ ]}
326
+ caption="Each card's box-shadow is colored by its token in your current scheme. The step is what the shadow lands where it is fully opaque."
327
+ />
328
+
329
+ A blurred `box-shadow` fades out from the token's color, so the step is the
330
+ darkest the shadow gets, right at the card's edge. In dark schemes the page
331
+ sits near black and a dark card shadow has little room: the solved opacity
332
+ climbs, and in dark high contrast the page is black, so all three shadows
333
+ stay fully transparent and warn. For elevation in dark schemes, use glows on a
334
+ surface with room in every scheme, as in the next section.
335
+
336
+ ## Dark-mode elevation
337
+
338
+ Glows are the mirror for dark-mode elevation and edge highlights: each level
339
+ lifts the surface a fixed number of tones. A glow resolves in all four
340
+ schemes, so it needs room above its surface in each of them. This ladder
341
+ sits on a light-gray surface, so the same tokens also lift cards toward white
342
+ in the light scheme.
343
+
344
+ <Example {...elevationTheme} visual="none" />
345
+
346
+ <ElevationLadder
347
+ {...elevationTheme}
348
+ surface="surface"
349
+ levels={['elevation-1', 'elevation-2', 'elevation-3']}
350
+ schemes={['dark', 'light']}
351
+ caption="The same three glows in the dark and light schemes. On the dark surface a few percent of white lifts it 3, 5, and 7 tones; on the light surface the same steps take far more opacity."
352
+ />
353
+
354
+ A glow on a white light-mode surface has nothing to lighten in SDR, so Glaze
355
+ warns, even if you only use the token in dark mode. On a tone-100 surface it
356
+ leaves the glow transparent. A white written as `'#ffffff'` parses a hair
357
+ below tone 100, so its glow pins to opaque white instead, which paints the
358
+ same over it. Give glows a surface with room in every scheme, like the
359
+ tone-92 surface here.
360
+
361
+ [HDR headroom](#hdr-glows) changes nothing below white. This ladder tops out
362
+ at tone 99.4 (light), so it resolves the same with or without it. Only a glow
363
+ whose step passes white, like one on that white surface, gets an HDR value.
364
+
365
+ ## Tinted pigment
366
+
367
+ The pigment is pure black or pure white by default, so the surface keeps its
368
+ own hue under the overlay and the output stays neutral. A pigment tinted in
369
+ the surface's hue ([Josh Comeau's shadow advice](https://www.joshwcomeau.com/css/designing-shadows))
370
+ is opt-in through tuning:
371
+
372
+ - **`reach`** sets where the pigment sits: the fraction of the way from the
373
+ surface's tone to black (shadow) or white (glow). `1` is pure black or
374
+ white, the most reach; lower values let a tint show at the cost of reach.
375
+ - **`tint`** is the pigment's saturation, in the surface's hue. It shows only
376
+ with `reach` below 1, since pure black and white have no hue. A neutral
377
+ surface has no hue to give, so there the tint takes the object's hue when
378
+ `hueBleed` is above 0, and the pigment stays neutral otherwise.
379
+ - **`hueBleed`** pulls the pigment's hue from the surface's hue toward the
380
+ object's (`fg`). It needs `fg`, a `tint`, and `reach` below 1 to show.
381
+ - **`objectInfluence`** is the `β` in [object influence](#object-influence).
382
+
383
+ ```ts
384
+ theme.colors({
385
+ 'shadow-tinted': {
386
+ type: 'shadow',
387
+ bg: 'surface',
388
+ intensity: 8,
389
+ tuning: { tint: 0.3, reach: 0.85 },
390
+ },
391
+ });
392
+
393
+ glaze.configure({
394
+ shadowTuning: { objectInfluence: 0.4 },
395
+ glowTuning: { objectInfluence: 0.6 },
396
+ });
397
+ ```
398
+
399
+ `shadowTuning` and `glowTuning` set the tuning globally (or per theme or token
400
+ through the config override); a per-color `tuning` merges over them field by
401
+ field. Both take these `LightingTuning` fields, and `glowTuning` adds
402
+ [`headroom`](#headroom); defaults are in
403
+ [Color definitions](./reference/color-definitions.md#lightingtuning) and
404
+ [Configuration](./reference/configuration.md#glazeconfig).
405
+
406
+ <TintedPigment
407
+ hue={60}
408
+ saturation={70}
409
+ surfaceSaturation={0.3}
410
+ tones={[97, 50, 12]}
411
+ intensity={8}
412
+ object={{ hue: 200, tone: 50 }}
413
+ columns={[
414
+ { label: 'neutral (default)', tuning: {} },
415
+ { label: 'tint 0.3 · reach 0.85', tuning: { tint: 0.3, reach: 0.85 } },
416
+ {
417
+ label: '+ hueBleed 0.6',
418
+ tuning: { tint: 0.3, reach: 0.85, hueBleed: 0.6, objectInfluence: 0 },
419
+ withObject: true,
420
+ },
421
+ ]}
422
+ caption="An 8-tone shadow on warm surfaces. The dot is the pigment at full opacity. The hueBleed column points fg at a hue-200 object and sets objectInfluence to 0, so only the hue changes."
423
+ />
424
+
425
+ The step is exact in every column. On light and mid surfaces a tint barely
426
+ changes the composite; on dark surfaces it costs reach and opacity. On the
427
+ figure's tone-12 surface, black reaches about 12 tones and needs α 0.545 for
428
+ the 8-tone shadow; the `tint: 0.3, reach: 0.85` pigment reaches about 10 tones
429
+ and needs α 0.706.
430
+
431
+ ## Unreachable steps
432
+
433
+ Every surface has limited room: a shadow cannot go below black, and a glow
434
+ cannot go above white. When the step is out of reach (a glow on white, a deep
435
+ shadow on near-black), Glaze pins the overlay to the pigment's strongest
436
+ composite (full opacity, or fully transparent when the pigment cannot move the
437
+ surface at all) and emits a deduplicated `console.warn`. A 6-tone glow on a
438
+ tone-100 `auto` surface logs:
439
+
440
+ ```text
441
+ glaze: glow "surface-highlight" cannot reach a 6.0-tone step in light
442
+ scheme (reached 0.0 at full strength).
443
+ Lower the intensity, move the surface away from white, or raise a
444
+ tuned-down `reach`.
445
+ ```
446
+
447
+ <Unreachable
448
+ cases={[
449
+ { kind: 'glow', tone: 100, intensity: 6 },
450
+ { kind: 'glow', tone: 97, intensity: 8 },
451
+ { kind: 'shadow', tone: 8, intensity: 12 },
452
+ ]}
453
+ caption="The big number is the step reached; asked is the step requested. A glow on white stays transparent; the other two pin to full-opacity white and black."
454
+ />
455
+
456
+ Every shadow and glow resolves in all four schemes on every resolve, whatever
457
+ `modes` you emit and wherever you use the token, so the warning fires even for
458
+ a scheme you never show. Not using a token in a scheme only keeps the pinned
459
+ value off the screen.
460
+
461
+ To fix it, lower `intensity`, or give the overlay a surface with room in
462
+ every scheme. A tuned-down `reach` also costs room; raise it back toward 1.
463
+ Otherwise, expect the warning. Watch dark high contrast in particular: it
464
+ bypasses the tone window, so a page that is white in light mode becomes black
465
+ there and every shadow on it pins and warns. A tone-97 page sits at tone 3,
466
+ which leaves room for less than 3 tones.
467
+
468
+ With [HDR headroom](#hdr-glows), a glow whose target passes white gets an HDR
469
+ value that lands the step, and this warning no longer fires for it. Only the
470
+ headroom cap can leave such a step short, and that logs its own warning. A
471
+ 30-tone glow on white with 1 stop of headroom:
472
+
473
+ ```text
474
+ glaze: glow "surface-flare" cannot reach a 30.0-tone step in light
475
+ scheme even with 1 stop of headroom (reached 22.0).
476
+ Lower the intensity or raise `headroom`.
477
+ ```
478
+
479
+ At the 2-stop maximum the second sentence reads "Lower the intensity;
480
+ `headroom` is already at its 2-stop maximum." A glow without an HDR value
481
+ keeps the SDR warning above.
482
+
483
+ ## HDR glows
484
+
485
+ A glow can't go past white in SDR, but an HDR display can show brighter than
486
+ white. Set `glowTuning.headroom`, and a glow whose step would pass white also
487
+ resolves an HDR value: an opaque exposure of what SDR already paints there.
488
+ The SDR value doesn't change, and exporters add the HDR one only when you
489
+ [opt in](#output-is-opt-in).
490
+
491
+ ### When a glow gets an HDR value
492
+
493
+ [Tone runs on past 100](./okhst.mdx#tone-above-100) on the same curve: one
494
+ stop above SDR white (`Y` 2) is tone 121.98, two stops (`Y` 4) tone 144.34,
495
+ and 12 tones past white is the same `Y + 0.05` ratio as a 12-tone step
496
+ anywhere. WCAG and APCA end at white, so Glaze makes no contrast claim above
497
+ 100. A glow can't be a `bg`, `fg`, `base`, or mix input, so an HDR value never
498
+ reaches the contrast solver.
499
+
500
+ A glow gets an HDR value when three things hold: its `headroom` is above 0,
501
+ its target tone (the surface's luminance tone plus `intensity × k`) passes
502
+ 100, and SDR is pinned at its brightest. That means the pigment at full
503
+ opacity, or a white surface (tone 100) at opacity 0, since no pigment can lift
504
+ it. Below white nothing changes, dark-scheme glows included. A pigment that
505
+ can't lift a non-white surface at all (`reach: 0` on a gray surface, say)
506
+ paints nothing in SDR, gets no HDR value, and keeps the SDR warning. On a
507
+ chromatic surface a `reach: 0` pigment, a gray at the surface's tone, can be
508
+ slightly brighter than the surface: it then pins at full opacity and, past
509
+ white, gets an HDR value like any pinned pigment.
510
+
511
+ The resolved variant gains `hdr: { h, s, t, exposure }`. The base (`h`, `s`,
512
+ `t`) is the opaque color SDR paints, with a standard (never pastel) OKHSL
513
+ saturation. The HDR value is that base times `2^exposure`, the gain that lands
514
+ the target tone exactly; `variant.t` itself stays at most 1. The value is
515
+ opaque on purpose: it stays exact whichever way a browser composites, and it
516
+ maps one to one onto native exposure APIs.
517
+
518
+ ### Headroom
519
+
520
+ `headroom` counts stops above SDR white, the doublings of its luminance. It
521
+ defaults to 0 (no HDR anywhere) and must be a finite number ≥ 0, or Glaze
522
+ throws. Set it globally (`glaze.configure({ glowTuning: { headroom: 1 } })`),
523
+ in a theme's config override, on one glow's `tuning`, or on
524
+ `glaze.glow()`. A glow's `tuning` merges over `glowTuning` field by field. A
525
+ theme's own `glowTuning` replaces the global one whole, and so does a later
526
+ `glaze.configure({ glowTuning })`, so pass every field you want kept:
527
+ `glaze.configure({ glowTuning: { objectInfluence: 0.6, headroom: 1 } })`. A
528
+ `headroom` on a shadow throws: nothing goes below black.
529
+
530
+ Values above 2 clamp to 2. The warning fires once per field and value, and
531
+ only after the whole input has validated:
532
+
533
+ ```text
534
+ glaze: glowTuning.headroom 4 is above the 2-stop maximum; using 2.
535
+ Headroom counts stops (doublings of SDR white's luminance), not a
536
+ ratio: 1 stop is 2× white, 2 stops 4×.
537
+ ```
538
+
539
+ The gain is capped per channel, because displays clip per channel: no channel
540
+ of the HDR value passes `2^headroom`. On a neutral glow that caps luminance: a
541
+ 30-tone glow on white with 1 stop of headroom ends at
542
+ `color(srgb-linear 2 2 2)`, tone 121.98, and warns (see
543
+ [Unreachable steps](#unreachable-steps)). A saturated base hits the cap on its
544
+ brightest channel first, so its HDR tone can stay below 100.
545
+
546
+ A display shows only as much as its current headroom allows and clips the
547
+ rest, and a page can't read that number. Apple quotes about 2× (1 stop) for
548
+ ordinary Mac and iPad displays, so 1 stop is a sensible ceiling for UI.
549
+
550
+ ### Worked example
551
+
552
+ Three 12-tone glows: one on a white `auto` surface, one on a light gray, and
553
+ one on a dark gray.
554
+
555
+ <Example
556
+ {...hdrTheme}
557
+ visual="none"
558
+ after={
559
+ "const glow = theme.resolve().get('surface-glow')!.light;\n" +
560
+ 'glaze.formatHdr(glow);\n' +
561
+ "// → 'color(srgb-linear 1.4631 1.4631 1.4631)'"
562
+ }
563
+ />
564
+
565
+ <HdrGlows
566
+ {...hdrTheme}
567
+ glows={['surface-glow', 'near-glow', 'inverse-glow']}
568
+ caption="Each glow over its surface, in your current scheme. The patch is the SDR result this page paints: the step it reached, the step asked, and its opacity. Below it: the HDR value's tone, its exposure, and its srgb-linear channels, or the target tone that stayed below white. This site renders SDR only."
569
+ />
570
+
571
+ The same numbers in every scheme:
572
+
573
+ | Glow | SDR | HDR |
574
+ | --- | --- | --- |
575
+ | `surface-glow`, light and light HC | White at α 0 | Linear 1.4631 per channel, 0.549 stops, tone 112 |
576
+ | `surface-glow`, dark and dark HC | White at α 0.1237 (α 0.1603 in dark HC) | None: the dark surface has room |
577
+ | `near-glow`, all four | Opaque white, 5 of 12 tones | Linear 1.2494 per channel, 0.321 stops, tone 107 |
578
+ | `inverse-glow`, all four | White at α 0.1155 | None: the target is tone 24 |
579
+
580
+ `near-glow` shows the split: SDR pins it to opaque white 7 tones short, and
581
+ its HDR value lands those 7. In dark, `surface` sits at tone 9.25 (luminance
582
+ tone 8.5, so the glow's target is tone 20.5), and `surface-glow` is an
583
+ ordinary translucent glow there.
584
+
585
+ ### Crossing white
586
+
587
+ A full-reach glow meets SDR at the boundary. On a tone-95 surface a 4.9-tone
588
+ glow is white at α 0.9794 and a 5-tone glow is opaque white, both without an
589
+ HDR value. At 5.1 tones it adds `color(srgb-linear 1.0032 1.0032 1.0032)`,
590
+ 0.0046 stops.
591
+
592
+ A glow with `reach` below 1 doesn't meet it. Its pigment sits below white, so
593
+ it stays short and warns until its target passes 100, then its HDR value lands
594
+ the whole step at once. A `reach: 0.5` glow on a tone-90 surface paints
595
+ tone 95 for any step from 5 to 10. Past 10, its HDR value jumps straight to
596
+ tone 100 (0.2315 stops over the pigment). Keep `reach` at 1 on a glow that
597
+ may cross white.
598
+
599
+ ### Output is opt-in
600
+
601
+ Resolving fills in `variant.hdr` whenever headroom allows, and
602
+ `glaze.formatHdr(variant)` writes it as `color(srgb-linear R G B)`. Exporters
603
+ leave it out until you turn on `modes.hdr`: globally with
604
+ `glaze.configure({ modes: { hdr: true } })`, or per call (`css()` takes
605
+ `hdr: true`). Until then every export is byte-identical, headroom or not.
606
+
607
+ HDR strings are always `color(srgb-linear …)`, whatever `format` you pass:
608
+ CSS clamps `oklch()` lightness at 1, and `rgb()` / `hsl()` can't go past
609
+ white. How each format carries them, and how to wire the CSS, is in
610
+ [HDR output](./exporting.mdx#hdr-output).
611
+
612
+ ### Browsers and native apps
613
+
614
+ As of November 2026, no browser reliably paints a CSS color brighter than
615
+ white:
616
+
617
+ | Engine or spec | What `color(srgb-linear 1.4631 1.4631 1.4631)` paints |
618
+ | --- | --- |
619
+ | CSS Color 4 | SDR white. Values past 1 are valid but gamut-mapped into range for display. |
620
+ | Chrome, Edge | Brighter than white only when it shares a composited layer with HDR image content; SDR white elsewhere. The CSS Working Group intends to remove that path. |
621
+ | Safari | SDR white. WebKit clamps extended colors on purpose, though `dynamic-range: high` matches on HDR-capable devices. |
622
+ | Firefox | SDR white. No CSS HDR. |
623
+ | CSS Color HDR (draft) | Defines `color-hdr()`, which blends colors given at different headroom stops (in practice an SDR and an HDR one), weighted by the display's headroom. No engine implements it. |
624
+
625
+ `@media (dynamic-range: high)` reports what the device can do, not that HDR is
626
+ on: Low Power Mode and the brightness setting change that. And the CSS
627
+ Working Group's direction keeps plain colors such as these in SDR unless they
628
+ opt in through `color-hdr()`, which Glaze doesn't emit yet. So don't count on
629
+ the CSS output to paint past white, today or under the planned standard.
630
+
631
+ Native apps can draw these colors today, and the JSON and DTCG exports carry
632
+ what they take. UIKit and AppKit (iOS and macOS 26) take the base plus
633
+ `exposure` in stops (`UIColor(red:green:blue:alpha:exposure:)` and its
634
+ `NSColor` twin); so does SwiftUI (`exposureAdjust(_:)`). Android takes the
635
+ linear channels as a `LINEAR_EXTENDED_SRGB` color in an HDR window.
636
+ [DTCG and native apps](./exporting.mdx#dtcg-and-native-apps) maps each field.
637
+
638
+ ### Fills, not overlays
639
+
640
+ Where the HDR value clamps, which today is most places, it paints an opaque
641
+ color: white, for a neutral glow. That matches SDR wherever SDR is already
642
+ opaque (`near-glow`). On a tone-100 surface it doesn't: SDR is transparent
643
+ there, and the clamped value covers anything drawn between the surface and the
644
+ glow.
645
+ Use HDR glows for fills (a card's or button's own background), edge
646
+ highlights, and `box-shadow` colors, never for a state layer drawn over a
647
+ label.
648
+
649
+ ### Power and accessibility
650
+
651
+ - HDR is an enhancement. The design must work in SDR, and it does: the SDR
652
+ value is what every other display already shows.
653
+ - Don't animate or flash HDR values. A pulsing glow past white is a large
654
+ luminance swing, and HDR flash thresholds now appear in the WCAG 2.3.1
655
+ Understanding draft and ITU-R BT.1702-3.
656
+ - Brighter-than-white pixels cost power, and systems fall back to SDR to save
657
+ it (iOS Low Power Mode does). Keep headroom low and HDR areas small.
658
+
659
+ ## High contrast
660
+
661
+ `intensity` accepts a `[normal, highContrast]` pair, like other numeric
662
+ fields:
663
+
664
+ ```ts
665
+ theme.colors({
666
+ 'card-shadow': {
667
+ type: 'shadow',
668
+ bg: 'surface',
669
+ fg: 'card',
670
+ intensity: [8, 14],
671
+ },
672
+ });
673
+ ```
674
+
675
+ With a manual contrast level, the pair is interpolated like every other pair.
676
+ See [high-contrast pairs and the contrast level](./reference/contrast.md#manual-contrast-level).
677
+
678
+ ## Fixed opacity
679
+
680
+ For one fixed-alpha color with no opacity solve, such as a modal scrim, set
681
+ `opacity` on a regular color:
682
+
683
+ ```ts
684
+ theme.colors({
685
+ scrim: { tone: 0, saturation: 0, mode: 'static', opacity: 0.5 },
686
+ });
687
+ // scrim → 'oklch(0 0 0 / 0.5)' in every scheme
688
+ ```
689
+
690
+ `opacity` is applied after the color resolves, so its tone still adapts like
691
+ any regular color. Without `mode: 'static'`, `tone: 0` is lifted by the light
692
+ tone window and inverted into a light overlay in dark mode. A fixed alpha
693
+ gives a different step on every surface (an 8% black layer darkens white by
694
+ about 6 tones and a tone-10 surface by about 1), which is what shadows and
695
+ glows exist to avoid. For a translucent color with a contrast floor, use a
696
+ [transparent mix](./mixes.mdx#transparent-mix).
697
+
698
+ ## Outside a theme
699
+
700
+ `glaze.shadow()` and `glaze.glow()` compute an overlay without a theme. `bg`
701
+ and `fg` accept any color value Glaze parses (hex, `rgb()`, `okhsl()`, value
702
+ objects, …):
703
+
704
+ ```ts
705
+ const shade = glaze.shadow({ bg: '#ffffff', intensity: 6 });
706
+ // → { h: 0, s: 0, t: 0, alpha: 0.0814… }
707
+
708
+ glaze.format(shade, 'rgb');
709
+ // → 'rgb(0 0 0 / 0.0814)'
710
+
711
+ const lift = glaze.glow({ bg: '#1e1e22', fg: '#3a3a44', intensity: 4 });
712
+ ```
713
+
714
+ Both accept an optional `tuning` that merges over the global `shadowTuning` /
715
+ `glowTuning`. With a `headroom` there, `glaze.glow()` also returns
716
+ [`hdr`](#hdr-glows) when its step passes white:
717
+
718
+ ```ts
719
+ const flare = glaze.glow({
720
+ bg: '#ffffff',
721
+ intensity: 12,
722
+ tuning: { headroom: 1 },
723
+ });
724
+
725
+ glaze.formatHdr(flare);
726
+ // → 'color(srgb-linear 1.4631 1.4631 1.4631)'
727
+ ```
728
+
729
+ The full input table is in
730
+ [Utilities](./reference/utilities.md#glazeshadowinput--glazeglowinput).
731
+
732
+ ## Coming from 2.x
733
+
734
+ In Glaze 2, `intensity` went through a curve in OKHSL lightness, so the same
735
+ number darkened white, mid, and dark surfaces by very different amounts, and
736
+ `fg` was a contrast reference that gave a white card on a light page almost no
737
+ shadow. 3.0 makes `intensity` an exact tone step, makes `fg` the object, and
738
+ adds `glow`. Upgrade steps are in
739
+ [3.0 — shadows and glows](./migration.md#30--shadows-and-glows).