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 |
| 7 | /** |
| 8 | * - `regular`: the default material. Diffuses and evens out the page so text |
| 9 | * on it stays legible over anything. |
| 10 | * - `clear`: only bends and lights the page; the body barely changes it. For |
| 11 | * surfaces over media or calm backgrounds. |
| 12 | * - `lens`: the clear lens a control lifts into, like a segmented control's |
| 13 | * thumb. It lifts what is beneath a little, and its edge is darker at the |
| 14 | * rounded ends than a surface's bright rim. Pair with LENS_GEOMETRY. |
| 15 | */ |
| 16 | export type GlassVariant = "regular" | "clear" | "lens"; |
| 17 |
| 18 | export type GlassTone = { |
| 19 | /** Body blur, in CSS pixels. */ |
| 20 | blur: number; |
| 21 | /** Body blur of floating surfaces (menus, popovers, dialogs), which diffuse more. */ |
| 22 | floatingBlur: 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 | highlightContrast: number; |
| 29 | highlightBrightness: number; |
| 30 | shadeBrightness: number; |
| 31 | glowSaturate: number; |
| 32 | glowBrightness: number; |
| 33 | /** The darkened edge's brightness. */ |
| 34 | edgeBrightness: number; |
| 35 | }; |
| 36 |
| 37 | // The rim of every variant: a hairline bright enough for its glints, side shade, |
| 38 | // inner glow and the darkened edge. In dark the glints get the extra light; in |
| 39 | // light the hairline is a white line inside the edge. |
| 40 | const RIM: Record<GlassAppearance, Pick<GlassTone, "highlightContrast" | "highlightBrightness" | "shadeBrightness" | "glowSaturate" | "glowBrightness" | "edgeBrightness">> = { |
| 41 | dark: { |
| 42 | highlightContrast: 1.1, |
| 43 | highlightBrightness: 2.4, |
| 44 | shadeBrightness: 1.33, |
| 45 | glowSaturate: 1.1, |
| 46 | glowBrightness: 1.3, |
| 47 | edgeBrightness: 0.66, |
| 48 | }, |
| 49 | light: { |
| 50 | highlightContrast: 1, |
| 51 | highlightBrightness: 1.25, |
| 52 | shadeBrightness: 1.08, |
| 53 | glowSaturate: 1, |
| 54 | glowBrightness: 1.02, |
| 55 | edgeBrightness: 0.82, |
| 56 | }, |
| 57 | }; |
| 58 |
| 59 | export const TONES: Record<GlassVariant, Record<GlassAppearance, GlassTone>> = { |
| 60 | regular: { |
| 61 | // Dark glass lowers the page's contrast and brightness into an even, dim body. |
| 62 | // Untinted, that lifts a near-black page to a flat grey slab, so it takes half |
| 63 | // the tint colour. |
| 64 | dark: { |
| 65 | ...RIM.dark, |
| 66 | blur: 1.5, |
| 67 | floatingBlur: 6, |
| 68 | saturate: 0.8, |
| 69 | contrast: 0.53, |
| 70 | brightness: 0.43, |
| 71 | tint: 0.5, |
| 72 | }, |
| 73 | // Light glass lifts the page into a pale body and brightens its sides instead. |
| 74 | light: { |
| 75 | ...RIM.light, |
| 76 | blur: 2, |
| 77 | floatingBlur: 7, |
| 78 | saturate: 0.8, |
| 79 | contrast: 0.3, |
| 80 | brightness: 1.73, |
| 81 | tint: 0.4, |
| 82 | }, |
| 83 | }, |
| 84 | clear: { |
| 85 | dark: { |
| 86 | ...RIM.dark, |
| 87 | blur: 1.5, |
| 88 | floatingBlur: 1.5, |
| 89 | saturate: 1, |
| 90 | contrast: 1, |
| 91 | brightness: 1, |
| 92 | tint: 0, |
| 93 | }, |
| 94 | // A faint wash keeps text on light clear glass legible over bright pages. |
| 95 | light: { |
| 96 | ...RIM.light, |
| 97 | blur: 0, |
| 98 | floatingBlur: 0, |
| 99 | saturate: 1, |
| 100 | contrast: 1.02, |
| 101 | brightness: 1.02, |
| 102 | tint: 0.28, |
| 103 | }, |
| 104 | }, |
| 105 | lens: { |
| 106 | dark: { |
| 107 | ...RIM.dark, |
| 108 | blur: 0, |
| 109 | floatingBlur: 0, |
| 110 | saturate: 1.2, |
| 111 | contrast: 1, |
| 112 | brightness: 1.08, |
| 113 | tint: 0, |
| 114 | highlightBrightness: 1.5, |
| 115 | shadeBrightness: 0.8, |
| 116 | glowBrightness: 1.12, |
| 117 | }, |
| 118 | light: { |
| 119 | ...RIM.light, |
| 120 | blur: 0, |
| 121 | floatingBlur: 0, |
| 122 | saturate: 1.1, |
| 123 | contrast: 1, |
| 124 | brightness: 1.03, |
| 125 | tint: 0, |
| 126 | }, |
| 127 | }, |
| 128 | }; |
| 129 |
| 130 | /** The page brightened under a control sitting on the glass, at rest and hovered. */ |
| 131 | export const WELL_BRIGHTNESS: Record<GlassAppearance, { rest: number; hover: number }> = { |
| 132 | dark: { rest: 1.45, hover: 1.8 }, |
| 133 | light: { rest: 0.92, hover: 0.86 }, |
| 134 | }; |
| 135 |
| 136 | /** The tint colour when none is given: near-black and white. */ |
| 137 | export const DEFAULT_TINT: Record<GlassAppearance, string> = { |
| 138 | dark: "#101114", |
| 139 | light: "#fff", |
| 140 | }; |
| 141 |
| 142 | export type GlassFilter = { |
| 143 | /** The layer's backdrop-filter. */ |
| 144 | full: string; |
| 145 | /** The same functions at their identity values, for materializing from clear glass. */ |
| 146 | clear: string; |
| 147 | }; |
| 148 |
| 149 | type FilterFunction = [name: string, value: number, identity: number, unit?: string]; |
| 150 |
| 151 | function filter(functions: FilterFunction[]): GlassFilter { |
| 152 | // Identity functions are dropped so the browser runs fewer passes per frame; |
| 153 | // `clear` keeps the same list so the two interpolate function by function. |
| 154 | const used = functions.filter(([, value, identity]) => value !== identity); |
| 155 | if (used.length === 0) return { full: "none", clear: "none" }; |
| 156 | return { |
| 157 | full: used.map(([name, value, , unit = ""]) => `${name}(${value}${unit})`).join(" "), |
| 158 | clear: used.map(([name, , identity, unit = ""]) => `${name}(${identity}${unit})`).join(" "), |
| 159 | }; |
| 160 | } |
| 161 |
| 162 | /** Backdrop filters for each layer of a tone. Floating surfaces take the tone's floatingBlur. */ |
| 163 | export function toneFilters(tone: GlassTone, floating = false) { |
| 164 | return { |
| 165 | body: filter([ |
| 166 | ["blur", floating ? tone.floatingBlur : tone.blur, 0, "px"], |
| 167 | ["saturate", tone.saturate, 1], |
| 168 | ["contrast", tone.contrast, 1], |
| 169 | ["brightness", tone.brightness, 1], |
| 170 | ]), |
| 171 | shade: filter([["brightness", tone.shadeBrightness, 1]]), |
| 172 | glow: filter([ |
| 173 | ["saturate", tone.glowSaturate, 1], |
| 174 | ["brightness", tone.glowBrightness, 1], |
| 175 | ]), |
| 176 | edge: filter([["brightness", tone.edgeBrightness, 1]]), |
| 177 | highlight: filter([ |
| 178 | ["contrast", tone.highlightContrast, 1], |
| 179 | ["brightness", tone.highlightBrightness, 1], |
| 180 | ]), |
| 181 | }; |
| 182 | } |
| 183 |
| 184 | /** The body's wash: a share of the tint colour over the filtered page. */ |
| 185 | export function toneWash(tone: GlassTone, tint: string) { |
| 186 | if (tone.tint <= 0) return "transparent"; |
| 187 | return `color-mix(in srgb, ${tint} ${Math.round(Math.min(tone.tint, 1) * 100)}%, transparent)`; |
| 188 | } |
| 189 |
| 190 | export type GlassToneOverrides = Partial<Record<GlassAppearance, Partial<GlassTone>>>; |
| 191 |
| 192 | export function resolveTone(variant: GlassVariant, appearance: GlassAppearance, overrides?: GlassToneOverrides): GlassTone { |
| 193 | return { ...TONES[variant][appearance], ...overrides?.[appearance] }; |
| 194 | } |
| 195 |
| 196 | const LUMA = [0.2126, 0.7152, 0.0722] as const; |
| 197 |
| 198 | /** How much of a hue the glass takes by default. Hue measured from near-black dark |
| 199 | * surfaces reads stronger than it looks on the surface itself. */ |
| 200 | export const DEFAULT_HUE_STRENGTH: Record<GlassAppearance, number> = { light: 1, dark: 0.5 }; |
| 201 |
| 202 | /** |
| 203 | * An feColorMatrix that tints the backdrop with a colour's hue the way Apple tints |
| 204 | * its glass: each pixel shifts toward the hue at its own luma, so dark content takes |
| 205 | * a deep tone and light content a pale one, and colours behind keep their chroma. |
| 206 | * Takes 0–255 channels. Returns null for neutral colours, which leave glass untinted. |
| 207 | */ |
| 208 | export function hueMatrix(rgb: readonly [number, number, number], strength: number): string | null { |
| 209 | const colour = rgb.map(channel => channel / 255); |
| 210 | const luma = colour.reduce((sum, channel, index) => sum + channel * (LUMA[index] ?? 0), 0); |
| 211 | const gains = colour.map(channel => (luma > 0 ? channel / luma : 1)); |
| 212 | if (Math.max(...gains.map(gain => Math.abs(gain - 1))) < 0.005) return null; |
| 213 | const rows = gains.map((gain, channel) => { |
| 214 | const weights = LUMA.map((weight, index) => (channel === index ? 1 : 0) + strength * (gain - 1) * weight); |
| 215 | return [...weights, 0, 0].map(value => value.toFixed(4)).join(" "); |
| 216 | }); |
| 217 | return [...rows, "0 0 0 1 0"].join(" "); |
| 218 | } |
| 219 |