Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 6 | export type GlassAppearance = "dark" | "light"; |
| 7 |
| 8 | /** |
| 9 | * - `regular`: the default material, as clear or tinted as its `level`. It |
| 10 | * diffuses the page enough that text on it stays legible over anything. |
| 11 | * - `clear`: regular glass at level 0, iOS 27's More Clear: the page beneath |
| 12 | * barely changes but for a trace of wash and the rim. For surfaces over media |
| 13 | * or calm backgrounds. |
| 14 | * - `lens`: the clear lens a control lifts into, like a segmented control's |
| 15 | * thumb. What is beneath shows unlifted, and its edge is darker at the rounded |
| 16 | * ends than a surface's bright rim. Pair with LENS_GEOMETRY. |
| 17 | */ |
| 18 | export type GlassVariant = "regular" | "clear" | "lens"; |
| 19 |
| 20 | export type GlassTone = { |
| 21 | /** Body blur, in CSS pixels. */ |
| 22 | blur: number; |
| 23 | saturate: number; |
| 24 | contrast: number; |
| 25 | brightness: number; |
| 26 | /** Share of the tint colour washed over the body (0–1). */ |
| 27 | tint: number; |
| 28 | /** |
| 29 | * The body of floating surfaces (menus, popovers, dialogs), which diffuse the |
| 30 | * page more, so the text beneath their own text washes into soft colour |
| 31 | * instead of reading through. |
| 32 | */ |
| 33 | floatingBlur: number; |
| 34 | floatingSaturate: number; |
| 35 | floatingContrast: number; |
| 36 | floatingBrightness: number; |
| 37 | floatingTint: number; |
| 38 | highlightContrast: number; |
| 39 | highlightBrightness: number; |
| 40 | shadeBrightness: number; |
| 41 | glowSaturate: number; |
| 42 | glowBrightness: number; |
| 43 | /** The darkened edge's brightness. */ |
| 44 | edgeBrightness: number; |
| 45 | }; |
| 46 |
| 47 | type RimTone = Pick<GlassTone, "highlightContrast" | "highlightBrightness" | "shadeBrightness" | "glowSaturate" | "glowBrightness" | "edgeBrightness">; |
| 48 | type BodyTone = Omit<GlassTone, keyof RimTone>; |
| 49 |
| 50 | // The rim of every variant: a hairline bright enough for its glints, side shade, |
| 51 | // inner glow and the darkened edge. In dark the glints get the extra light; in |
| 52 | // light the hairline is a white line inside the edge. The rim doesn't change with |
| 53 | // the level, as iOS 27's doesn't. |
| 54 | const RIM: Record<GlassAppearance, RimTone> = { |
| 55 | dark: { |
| 56 | highlightContrast: 1.1, |
| 57 | highlightBrightness: 2.4, |
| 58 | shadeBrightness: 1.33, |
| 59 | glowSaturate: 1.1, |
| 60 | glowBrightness: 1.3, |
| 61 | edgeBrightness: 0.66, |
| 62 | }, |
| 63 | light: { |
| 64 | highlightContrast: 1, |
| 65 | highlightBrightness: 1.25, |
| 66 | shadeBrightness: 1.08, |
| 67 | glowSaturate: 1, |
| 68 | glowBrightness: 1.02, |
| 69 | edgeBrightness: 0.82, |
| 70 | }, |
| 71 | }; |
| 72 |
| 73 | /** Regular glass's level when none is given: iOS 27's slider at its middle. */ |
| 74 | export const DEFAULT_LEVEL = 0.5; |
| 75 |
| 76 | const round = (value: number) => Math.round(value * 10000) / 10000; |
| 77 |
| 78 | /** |
| 79 | * The body of regular glass at a level of iOS 27's Liquid Glass slider: 0 is More |
| 80 | * Clear, 0.5 the default and 1 More Tinted, anywhere in between. Clear glass barely |
| 81 | * touches what's behind it; tinted glass covers it in the tint colour, frosted. Each |
| 82 | * value runs straight between the three stops: `a` goes 0 → 1 from Clear to the |
| 83 | * default, `b` from the default to Tinted. The default sits clearer than iOS 27's |
| 84 | * own (a light frost and a faint wash) and More Tinted is iOS 27's default. |
| 85 | * Floating glass keeps enough frost at every stop to read. |
| 86 | */ |
| 87 | export function levelTone(appearance: GlassAppearance, level = DEFAULT_LEVEL): BodyTone { |
| 88 | const clamped = Math.min(Math.max(level, 0), 1); |
| 89 | const a = Math.min(clamped * 2, 1); |
| 90 | const b = Math.max(clamped * 2 - 1, 0); |
| 91 | const body = |
| 92 | appearance === "light" |
| 93 | ? // Light glass washes what's behind it with the tint and frosts it: a trace at |
| 94 | // Clear, about a sixth by default, two fifths at Tinted, keeping its colour |
| 95 | // throughout (a pink tile shows as light pink). |
| 96 | { |
| 97 | blur: 1 * a + 4 * b, |
| 98 | saturate: 1.05 + 0.25 * a, |
| 99 | contrast: 1 - 0.256 * b, |
| 100 | brightness: 1 + 0.173 * b, |
| 101 | tint: 0.04 + 0.135 * a + 0.225 * b, |
| 102 | floatingBlur: 2 + 2.5 * a + 5.5 * b, |
| 103 | floatingSaturate: 1.2 + 0.15 * a - 0.05 * b, |
| 104 | floatingContrast: 0.75 - 0.25 * a - 0.2 * b, |
| 105 | floatingBrightness: 1.15 + 0.205 * a + 0.185 * b, |
| 106 | floatingTint: 0.1 + 0.14 * a + 0.16 * b, |
| 107 | } |
| 108 | : // Dark glass is a smoky film, as iOS 27's is at every setting: it pulls what's |
| 109 | // behind it toward a dark grey and keeps its hue, so a green page under it |
| 110 | // makes dark green glass. By default it keeps about half a bright colour's |
| 111 | // light; at Tinted it lifts black to about an eighth; at Clear it barely dims. |
| 112 | // Over it lies the tint, so the glass is your dark, not one grey everywhere. |
| 113 | { |
| 114 | blur: 1 * a + 4 * b, |
| 115 | saturate: 1.05 + 0.275 * a + 0.075 * b, |
| 116 | contrast: 0.97 - 0.189 * a - 0.153 * b, |
| 117 | brightness: 0.96 - 0.1685 * a - 0.0645 * b, |
| 118 | tint: 0.05 + 0.185 * a + 0.265 * b, |
| 119 | floatingBlur: 2.5 + 3.5 * a + 6 * b, |
| 120 | floatingSaturate: 1.3 + 0.15 * a + 0.05 * b, |
| 121 | floatingContrast: 0.88 - 0.156 * a - 0.142 * b, |
| 122 | floatingBrightness: 0.86 - 0.1155 * a - 0.0815 * b, |
| 123 | floatingTint: 0.14 + 0.17 * a + 0.19 * b, |
| 124 | }; |
| 125 | return Object.fromEntries(Object.entries(body).map(([key, value]) => [key, round(value)])) as BodyTone; |
| 126 | } |
| 127 |
| 128 | // The segmented control's lens: what is beneath shows as it is, a touch more |
| 129 | // saturated in dark. Its rim is darker at the rounded ends with soft light along |
| 130 | // the top and bottom, rather than a surface's bright rim, which ringed it like a |
| 131 | // lit outline. |
| 132 | const LENS_BODY: BodyTone = { |
| 133 | blur: 0, |
| 134 | saturate: 1, |
| 135 | contrast: 1, |
| 136 | brightness: 1, |
| 137 | tint: 0, |
| 138 | floatingBlur: 0, |
| 139 | floatingSaturate: 1, |
| 140 | floatingContrast: 1, |
| 141 | floatingBrightness: 1, |
| 142 | floatingTint: 0, |
| 143 | }; |
| 144 |
| 145 | export const TONES: Record<GlassVariant, Record<GlassAppearance, GlassTone>> = { |
| 146 | regular: { |
| 147 | dark: { ...RIM.dark, ...levelTone("dark") }, |
| 148 | light: { ...RIM.light, ...levelTone("light") }, |
| 149 | }, |
| 150 | clear: { |
| 151 | dark: { ...RIM.dark, ...levelTone("dark", 0) }, |
| 152 | light: { ...RIM.light, ...levelTone("light", 0) }, |
| 153 | }, |
| 154 | lens: { |
| 155 | dark: { |
| 156 | ...RIM.dark, |
| 157 | ...LENS_BODY, |
| 158 | saturate: 1.05, |
| 159 | floatingSaturate: 1.05, |
| 160 | highlightBrightness: 1.5, |
| 161 | shadeBrightness: 0.8, |
| 162 | glowBrightness: 1.12, |
| 163 | }, |
| 164 | light: { ...RIM.light, ...LENS_BODY }, |
| 165 | }, |
| 166 | }; |
| 167 |
| 168 | /** The page brightened under a control sitting on the glass, at rest and hovered. */ |
| 169 | export const WELL_BRIGHTNESS: Record<GlassAppearance, { rest: number; hover: number }> = { |
| 170 | dark: { rest: 1.45, hover: 1.8 }, |
| 171 | light: { rest: 0.92, hover: 0.86 }, |
| 172 | }; |
| 173 |
| 174 | /** The tint colour when none is given: near-black and white. */ |
| 175 | export const DEFAULT_TINT: Record<GlassAppearance, string> = { |
| 176 | dark: "#101114", |
| 177 | light: "#fff", |
| 178 | }; |
| 179 |
| 180 | export type GlassFilter = { |
| 181 | /** The layer's backdrop-filter. */ |
| 182 | full: string; |
| 183 | /** The same functions at their identity values, for materializing from clear glass. */ |
| 184 | clear: string; |
| 185 | }; |
| 186 |
| 187 | type FilterFunction = [name: string, value: number, identity: number, unit?: string]; |
| 188 |
| 189 | function filter(functions: FilterFunction[]): GlassFilter { |
| 190 | // Identity functions are dropped so the browser runs fewer passes per frame; |
| 191 | // `clear` keeps the same list so the two interpolate function by function. |
| 192 | const used = functions.filter(([, value, identity]) => value !== identity); |
| 193 | if (used.length === 0) return { full: "none", clear: "none" }; |
| 194 | return { |
| 195 | full: used.map(([name, value, , unit = ""]) => `${name}(${value}${unit})`).join(" "), |
| 196 | clear: used.map(([name, , identity, unit = ""]) => `${name}(${identity}${unit})`).join(" "), |
| 197 | }; |
| 198 | } |
| 199 |
| 200 | /** Backdrop filters for each layer of a tone. Floating surfaces take the tone's floating body. */ |
| 201 | export function toneFilters(tone: GlassTone, floating = false) { |
| 202 | return { |
| 203 | body: filter([ |
| 204 | ["blur", floating ? tone.floatingBlur : tone.blur, 0, "px"], |
| 205 | ["saturate", floating ? tone.floatingSaturate : tone.saturate, 1], |
| 206 | ["contrast", floating ? tone.floatingContrast : tone.contrast, 1], |
| 207 | ["brightness", floating ? tone.floatingBrightness : tone.brightness, 1], |
| 208 | ]), |
| 209 | shade: filter([["brightness", tone.shadeBrightness, 1]]), |
| 210 | glow: filter([ |
| 211 | ["saturate", tone.glowSaturate, 1], |
| 212 | ["brightness", tone.glowBrightness, 1], |
| 213 | ]), |
| 214 | edge: filter([["brightness", tone.edgeBrightness, 1]]), |
| 215 | highlight: filter([ |
| 216 | ["contrast", tone.highlightContrast, 1], |
| 217 | ["brightness", tone.highlightBrightness, 1], |
| 218 | ]), |
| 219 | }; |
| 220 | } |
| 221 |
| 222 | /** The body's wash: a share of the tint colour over the filtered page. */ |
| 223 | export function toneWash(tone: GlassTone, tint: string, floating = false) { |
| 224 | const share = floating ? tone.floatingTint : tone.tint; |
| 225 | if (share <= 0) return "transparent"; |
| 226 | return `color-mix(in srgb, ${tint} ${round(Math.min(share, 1) * 100)}%, transparent)`; |
| 227 | } |
| 228 |
| 229 | export type GlassToneOverrides = Partial<Record<GlassAppearance, Partial<GlassTone>>>; |
| 230 |
| 231 | /** A variant's tone in an appearance, at a level for regular glass, with your overrides on top. */ |
| 232 | export function resolveTone( |
| 233 | variant: GlassVariant, |
| 234 | appearance: GlassAppearance, |
| 235 | overrides?: GlassToneOverrides, |
| 236 | level = DEFAULT_LEVEL, |
| 237 | ): GlassTone { |
| 238 | const base = variant === "regular" && level !== DEFAULT_LEVEL |
| 239 | ? { ...RIM[appearance], ...levelTone(appearance, level) } |
| 240 | : TONES[variant][appearance]; |
| 241 | return { ...base, ...overrides?.[appearance] }; |
| 242 | } |
| 243 |
| 244 | const LUMA = [0.2126, 0.7152, 0.0722] as const; |
| 245 |
| 246 | /** How much of a hue the glass takes by default. Hue measured from near-black dark |
| 247 | * surfaces reads stronger than it looks on the surface itself. */ |
| 248 | export const DEFAULT_HUE_STRENGTH: Record<GlassAppearance, number> = { light: 1, dark: 0.5 }; |
| 249 |
| 250 | /** |
| 251 | * An feColorMatrix that tints the backdrop with a colour's hue the way Apple tints |
| 252 | * its glass: each pixel shifts toward the hue at its own luma, so dark content takes |
| 253 | * a deep tone and light content a pale one, and colours behind keep their chroma. |
| 254 | * Takes 0–255 channels. Returns null for neutral colours, which leave glass untinted. |
| 255 | */ |
| 256 | export function hueMatrix(rgb: readonly [number, number, number], strength: number): string | null { |
| 257 | const colour = rgb.map(channel => channel / 255); |
| 258 | const luma = colour.reduce((sum, channel, index) => sum + channel * (LUMA[index] ?? 0), 0); |
| 259 | const gains = colour.map(channel => (luma > 0 ? channel / luma : 1)); |
| 260 | if (Math.max(...gains.map(gain => Math.abs(gain - 1))) < 0.005) return null; |
| 261 | const rows = gains.map((gain, channel) => { |
| 262 | const weights = LUMA.map((weight, index) => (channel === index ? 1 : 0) + strength * (gain - 1) * weight); |
| 263 | return [...weights, 0, 0].map(value => value.toFixed(4)).join(" "); |
| 264 | }); |
| 265 | return [...rows, "0 0 0 1 0"].join(" "); |
| 266 | } |
| 267 |