/* Tone: what each layer does to the page behind it. Every layer is the backdrop, filtered; nothing is painted over the glass apart from a wash of the tint colour. Values were measured against Apple's iOS 27 glass in both appearances and at each stop of its Clear to Tinted slider. */ export type GlassAppearance = "dark" | "light"; /** * - `regular`: the default material, as clear or tinted as its `level`. It * diffuses the page enough that text on it stays legible over anything. * - `clear`: regular glass at level 0, iOS 27's More Clear: the page beneath * barely changes but for a trace of wash and the rim. For surfaces over media * or calm backgrounds. * - `lens`: the clear lens a control lifts into, like a segmented control's * thumb. What is beneath shows unlifted, and its edge is darker at the rounded * ends than a surface's bright rim. Pair with LENS_GEOMETRY. */ export type GlassVariant = "regular" | "clear" | "lens"; export type GlassTone = { /** Body blur, in CSS pixels. */ blur: number; saturate: number; contrast: number; brightness: number; /** Share of the tint colour washed over the body (0–1). */ tint: number; /** * The body of floating surfaces (menus, popovers, dialogs), which diffuse the * page more, so the text beneath their own text washes into soft colour * instead of reading through. */ floatingBlur: number; floatingSaturate: number; floatingContrast: number; floatingBrightness: number; floatingTint: number; highlightContrast: number; highlightBrightness: number; shadeBrightness: number; glowSaturate: number; glowBrightness: number; /** The darkened edge's brightness. */ edgeBrightness: number; }; type RimTone = Pick; type BodyTone = Omit; // The rim of every variant: a hairline bright enough for its glints, side shade, // inner glow and the darkened edge. In dark the glints get the extra light; in // light the hairline is a white line inside the edge. The rim doesn't change with // the level, as iOS 27's doesn't. const RIM: Record = { dark: { highlightContrast: 1.1, highlightBrightness: 2.4, shadeBrightness: 1.33, glowSaturate: 1.1, glowBrightness: 1.3, edgeBrightness: 0.66, }, light: { highlightContrast: 1, highlightBrightness: 1.25, shadeBrightness: 1.08, glowSaturate: 1, glowBrightness: 1.02, edgeBrightness: 0.82, }, }; /** Regular glass's level when none is given: iOS 27's slider at its middle. */ export const DEFAULT_LEVEL = 0.5; const round = (value: number) => Math.round(value * 10000) / 10000; /** * The body of regular glass at a level of iOS 27's Liquid Glass slider: 0 is More * Clear, 0.5 the default and 1 More Tinted, anywhere in between. Clear glass barely * touches what's behind it; tinted glass covers it in the tint colour, frosted. Each * value runs straight between the three stops: `a` goes 0 → 1 from Clear to the * default, `b` from the default to Tinted. The default sits clearer than iOS 27's * own (a light frost and a faint wash) and More Tinted is iOS 27's default. * Floating glass keeps enough frost at every stop to read. */ export function levelTone(appearance: GlassAppearance, level = DEFAULT_LEVEL): BodyTone { const clamped = Math.min(Math.max(level, 0), 1); const a = Math.min(clamped * 2, 1); const b = Math.max(clamped * 2 - 1, 0); const body = appearance === "light" ? // Light glass washes what's behind it with the tint and frosts it: a trace at // Clear, about a sixth by default, two fifths at Tinted, keeping its colour // throughout (a pink tile shows as light pink). { blur: 1 * a + 4 * b, saturate: 1.05 + 0.25 * a, contrast: 1 - 0.256 * b, brightness: 1 + 0.173 * b, tint: 0.04 + 0.135 * a + 0.225 * b, floatingBlur: 2 + 2.5 * a + 5.5 * b, floatingSaturate: 1.2 + 0.15 * a - 0.05 * b, floatingContrast: 0.75 - 0.25 * a - 0.2 * b, floatingBrightness: 1.15 + 0.205 * a + 0.185 * b, floatingTint: 0.1 + 0.14 * a + 0.16 * b, } : // Dark glass is a smoky film, as iOS 27's is at every setting: it pulls what's // behind it toward a dark grey and keeps its hue, so a green page under it // makes dark green glass. By default it keeps about half a bright colour's // light; at Tinted it lifts black to about an eighth; at Clear it barely dims. // Over it lies the tint, so the glass is your dark, not one grey everywhere. { blur: 1 * a + 4 * b, saturate: 1.05 + 0.275 * a + 0.075 * b, contrast: 0.97 - 0.189 * a - 0.153 * b, brightness: 0.96 - 0.1685 * a - 0.0645 * b, tint: 0.05 + 0.185 * a + 0.265 * b, floatingBlur: 2.5 + 3.5 * a + 6 * b, floatingSaturate: 1.3 + 0.15 * a + 0.05 * b, floatingContrast: 0.88 - 0.156 * a - 0.142 * b, floatingBrightness: 0.86 - 0.1155 * a - 0.0815 * b, floatingTint: 0.14 + 0.17 * a + 0.19 * b, }; return Object.fromEntries(Object.entries(body).map(([key, value]) => [key, round(value)])) as BodyTone; } // The segmented control's lens: what is beneath shows as it is, a touch more // saturated in dark. Its rim is darker at the rounded ends with soft light along // the top and bottom, rather than a surface's bright rim, which ringed it like a // lit outline. const LENS_BODY: BodyTone = { blur: 0, saturate: 1, contrast: 1, brightness: 1, tint: 0, floatingBlur: 0, floatingSaturate: 1, floatingContrast: 1, floatingBrightness: 1, floatingTint: 0, }; export const TONES: Record> = { regular: { dark: { ...RIM.dark, ...levelTone("dark") }, light: { ...RIM.light, ...levelTone("light") }, }, clear: { dark: { ...RIM.dark, ...levelTone("dark", 0) }, light: { ...RIM.light, ...levelTone("light", 0) }, }, lens: { dark: { ...RIM.dark, ...LENS_BODY, saturate: 1.05, floatingSaturate: 1.05, highlightBrightness: 1.5, shadeBrightness: 0.8, glowBrightness: 1.12, }, light: { ...RIM.light, ...LENS_BODY }, }, }; /** The page brightened under a control sitting on the glass, at rest and hovered. */ export const WELL_BRIGHTNESS: Record = { dark: { rest: 1.45, hover: 1.8 }, light: { rest: 0.92, hover: 0.86 }, }; /** The tint colour when none is given: near-black and white. */ export const DEFAULT_TINT: Record = { dark: "#101114", light: "#fff", }; export type GlassFilter = { /** The layer's backdrop-filter. */ full: string; /** The same functions at their identity values, for materializing from clear glass. */ clear: string; }; type FilterFunction = [name: string, value: number, identity: number, unit?: string]; function filter(functions: FilterFunction[]): GlassFilter { // Identity functions are dropped so the browser runs fewer passes per frame; // `clear` keeps the same list so the two interpolate function by function. const used = functions.filter(([, value, identity]) => value !== identity); if (used.length === 0) return { full: "none", clear: "none" }; return { full: used.map(([name, value, , unit = ""]) => `${name}(${value}${unit})`).join(" "), clear: used.map(([name, , identity, unit = ""]) => `${name}(${identity}${unit})`).join(" "), }; } /** Backdrop filters for each layer of a tone. Floating surfaces take the tone's floating body. */ export function toneFilters(tone: GlassTone, floating = false) { return { body: filter([ ["blur", floating ? tone.floatingBlur : tone.blur, 0, "px"], ["saturate", floating ? tone.floatingSaturate : tone.saturate, 1], ["contrast", floating ? tone.floatingContrast : tone.contrast, 1], ["brightness", floating ? tone.floatingBrightness : tone.brightness, 1], ]), shade: filter([["brightness", tone.shadeBrightness, 1]]), glow: filter([ ["saturate", tone.glowSaturate, 1], ["brightness", tone.glowBrightness, 1], ]), edge: filter([["brightness", tone.edgeBrightness, 1]]), highlight: filter([ ["contrast", tone.highlightContrast, 1], ["brightness", tone.highlightBrightness, 1], ]), }; } /** The body's wash: a share of the tint colour over the filtered page. */ export function toneWash(tone: GlassTone, tint: string, floating = false) { const share = floating ? tone.floatingTint : tone.tint; if (share <= 0) return "transparent"; return `color-mix(in srgb, ${tint} ${round(Math.min(share, 1) * 100)}%, transparent)`; } export type GlassToneOverrides = Partial>>; /** A variant's tone in an appearance, at a level for regular glass, with your overrides on top. */ export function resolveTone( variant: GlassVariant, appearance: GlassAppearance, overrides?: GlassToneOverrides, level = DEFAULT_LEVEL, ): GlassTone { const base = variant === "regular" && level !== DEFAULT_LEVEL ? { ...RIM[appearance], ...levelTone(appearance, level) } : TONES[variant][appearance]; return { ...base, ...overrides?.[appearance] }; } const LUMA = [0.2126, 0.7152, 0.0722] as const; /** How much of a hue the glass takes by default. Hue measured from near-black dark * surfaces reads stronger than it looks on the surface itself. */ export const DEFAULT_HUE_STRENGTH: Record = { light: 1, dark: 0.5 }; /** * An feColorMatrix that tints the backdrop with a colour's hue the way Apple tints * its glass: each pixel shifts toward the hue at its own luma, so dark content takes * a deep tone and light content a pale one, and colours behind keep their chroma. * Takes 0–255 channels. Returns null for neutral colours, which leave glass untinted. */ export function hueMatrix(rgb: readonly [number, number, number], strength: number): string | null { const colour = rgb.map(channel => channel / 255); const luma = colour.reduce((sum, channel, index) => sum + channel * (LUMA[index] ?? 0), 0); const gains = colour.map(channel => (luma > 0 ? channel / luma : 1)); if (Math.max(...gains.map(gain => Math.abs(gain - 1))) < 0.005) return null; const rows = gains.map((gain, channel) => { const weights = LUMA.map((weight, index) => (channel === index ? 1 : 0) + strength * (gain - 1) * weight); return [...weights, 0, 0].map(value => value.toFixed(4)).join(" "); }); return [...rows, "0 0 0 1 0"].join(" "); }