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.
| 1 | /* liquid-glass. Layout and accessibility for the glass; its filters are set |
| 2 | inline by the script, as literal values Safari can read. */ |
| 3 | |
| 4 | /* The glass sits behind the surface's content, so the surface must be its own |
| 5 | stacking context. It must not be a backdrop root itself: no filter, opacity |
| 6 | below 1, mask or backdrop-filter, or the glass would only see the surface. */ |
| 7 | :has |
| 8 | isolation: isolate; |
| 9 | } |
| 10 |
| 11 | .liquid-glass, |
| 12 | .liquid-glass-layer, |
| 13 | .liquid-glass-hue, |
| 14 | .liquid-glass-light { |
| 15 | position: absolute; |
| 16 | inset: 0; |
| 17 | border-radius: inherit; |
| 18 | pointer-events: none; |
| 19 | } |
| 20 | .liquid-glass { |
| 21 | z-index: -1; |
| 22 | } |
| 23 | .liquid-glass[data-fit="fixed"] { |
| 24 | position: fixed; |
| 25 | } |
| 26 | .liquid-glass[data-fit="scroll"] { |
| 27 | bottom: auto; |
| 28 | } |
| 29 | .liquid-glass-layer[data-layer="body"] { |
| 30 | transition: backdrop-filter 300ms ease, background-color 300ms ease; |
| 31 | } |
| 32 | /* Rim layers wait for their masks; unmasked, they would brighten the whole surface. */ |
| 33 | .liquid-glass:not([data-ready]) > :is([data-layer="shade"], [data-layer="glow"], [data-layer="edge"], [data-layer="highlight"]) { |
| 34 | display: none; |
| 35 | } |
| 36 |
| 37 | /* Floating glass (the `floating` option) casts a drop shadow from its surface, |
| 38 | without a ring or lit line: the glass draws its own edge, and an inset line |
| 39 | would sit under the glass and bend with it. Set --liquid-glass-shadow to change it. */ |
| 40 | [data-liquid-glass][data-liquid-glass-floating] { |
| 41 | box-shadow: var(--liquid-glass-shadow, 0 8px 20px -6px rgb(0 0 0 / 0.5), 0 24px 48px -12px rgb(0 0 0 / 0.6)); |
| 42 | } |
| 43 | [data-liquid-glass="light"][data-liquid-glass-floating] { |
| 44 | box-shadow: var(--liquid-glass-shadow, 0 6px 16px -4px rgb(30 30 40 / 0.1), 0 20px 40px -12px rgb(30 30 40 / 0.16)); |
| 45 | } |
| 46 | /* A dialog sits higher, so its shadow is deeper and wider (`floating: "dialog"`). */ |
| 47 | [data-liquid-glass][data-liquid-glass-floating="dialog"] { |
| 48 | box-shadow: var(--liquid-glass-dialog-shadow, 0 6px 12px -4px rgb(0 0 0 / 0.3), 0 20px 40px -12px rgb(0 0 0 / 0.5), 0 48px 96px -24px rgb(0 0 0 / 0.7)); |
| 49 | } |
| 50 | [data-liquid-glass="light"][data-liquid-glass-floating="dialog"] { |
| 51 | box-shadow: var(--liquid-glass-dialog-shadow, 0 6px 12px -4px rgb(30 30 40 / 0.06), 0 20px 40px -12px rgb(30 30 40 / 0.14), 0 48px 96px -24px rgb(30 30 40 / 0.22)); |
| 52 | } |
| 53 |
| 54 | /* Interactive glass (the `interactive` option) lights up from within: the pointer |
| 55 | warms a patch of glass beneath it, and a press blooms out from there. Letting |
| 56 | go, the light fades where it spread and only then gathers back. Plain alpha, |
| 57 | never a blend mode, which would isolate the glass and cut it off from the page. */ |
| 58 | @property --liquid-glass-light-size { |
| 59 | syntax: "<length>"; |
| 60 | inherits: false; |
| 61 | initial-value: 56px; |
| 62 | } |
| 63 | @property --liquid-glass-light { |
| 64 | syntax: "<number>"; |
| 65 | inherits: false; |
| 66 | initial-value: 0; |
| 67 | } |
| 68 | .liquid-glass-light { |
| 69 | --liquid-glass-light-size: 56px; |
| 70 | --liquid-glass-light: 0; |
| 71 | background: radial-gradient( |
| 72 | circle var(--liquid-glass-light-size) at var(--liquid-glass-x, -999px) var(--liquid-glass-y, -999px), |
| 73 | rgb(255 255 255 / var(--liquid-glass-light)), |
| 74 | rgb(255 255 255 / calc(var(--liquid-glass-light) * 0.3)) 40%, |
| 75 | transparent |
| 76 | ); |
| 77 | transition: --liquid-glass-light 360ms ease-out, --liquid-glass-light-size 240ms ease-out 360ms; |
| 78 | } |
| 79 | [data-liquid-glass-pointer] > .liquid-glass > .liquid-glass-light { |
| 80 | --liquid-glass-light: 0.07; |
| 81 | } |
| 82 | [data-liquid-glass-pressed] > .liquid-glass > .liquid-glass-light { |
| 83 | --liquid-glass-light-size: 150px; |
| 84 | --liquid-glass-light: 0.24; |
| 85 | transition: --liquid-glass-light 90ms ease-out, --liquid-glass-light-size 450ms cubic-bezier(0.23, 1, 0.32, 1); |
| 86 | } |
| 87 | [data-liquid-glass="light"][data-liquid-glass-pointer] > .liquid-glass > .liquid-glass-light { |
| 88 | --liquid-glass-light: 0.3; |
| 89 | } |
| 90 | [data-liquid-glass="light"][data-liquid-glass-pressed] > .liquid-glass > .liquid-glass-light { |
| 91 | --liquid-glass-light: 0.6; |
| 92 | } |
| 93 |
| 94 | /* Controls on a glass surface don't get glass of their own: Apple's guidance is |
| 95 | to avoid glass on glass and make them a thin part of the material. A well |
| 96 | brightens the glass beneath it. No element between it and the surface may be |
| 97 | a backdrop root. */ |
| 98 | [data-liquid-glass] .liquid-glass-well { |
| 99 | background: transparent; |
| 100 | -webkit-backdrop-filter: brightness(1.45); |
| 101 | backdrop-filter: brightness(1.45); |
| 102 | transition: backdrop-filter 150ms ease-out, scale 150ms ease-out, color 150ms ease-out; |
| 103 | } |
| 104 | [data-liquid-glass] .liquid-glass-well:hover { |
| 105 | -webkit-backdrop-filter: brightness(1.8); |
| 106 | backdrop-filter: brightness(1.8); |
| 107 | } |
| 108 | [data-liquid-glass="light"] .liquid-glass-well { |
| 109 | -webkit-backdrop-filter: brightness(0.92); |
| 110 | backdrop-filter: brightness(0.92); |
| 111 | } |
| 112 | [data-liquid-glass="light"] .liquid-glass-well:hover { |
| 113 | -webkit-backdrop-filter: brightness(0.86); |
| 114 | backdrop-filter: brightness(0.86); |
| 115 | } |
| 116 | /* A well can't fade with its surface's contents, or the glass beneath would blank, |
| 117 | so it eases back to clear glass as the surface dematerializes. */ |
| 118 | [data-liquid-glass][data-liquid-glass-leaving] .liquid-glass-well { |
| 119 | -webkit-backdrop-filter: brightness(1); |
| 120 | backdrop-filter: brightness(1); |
| 121 | } |
| 122 |
| 123 | /* Materializing, for surfaces that appear and disappear under CSS control: run |
| 124 | these on .liquid-glass-layer with the surface's own timing. The surface must |
| 125 | not fade or scale instead: opacity hides the glass, and a changing scale leaves |
| 126 | Chromium's backdrop-filter with a blank backdrop for frames. The script sets |
| 127 | each layer's --liquid-glass-clear, --liquid-glass-full and --liquid-glass-wash. |
| 128 | materialize() and dematerialize() do the same from script. */ |
| 129 | @keyframes liquid-glass-materialize { |
| 130 | from { |
| 131 | backdrop-filter: var(--liquid-glass-clear); |
| 132 | background-color: transparent; |
| 133 | } |
| 134 | to { |
| 135 | backdrop-filter: var(--liquid-glass-full); |
| 136 | background-color: var(--liquid-glass-wash, transparent); |
| 137 | } |
| 138 | } |
| 139 | @keyframes liquid-glass-dematerialize { |
| 140 | from { |
| 141 | backdrop-filter: var(--liquid-glass-full); |
| 142 | background-color: var(--liquid-glass-wash, transparent); |
| 143 | } |
| 144 | to { |
| 145 | backdrop-filter: var(--liquid-glass-clear); |
| 146 | background-color: transparent; |
| 147 | } |
| 148 | } |
| 149 |
| 150 | /* Tabs (attachLiquidGlassTabs). The selected tab is a well in the track's glass; |
| 151 | moving, the thumb lifts into a clear lens above the labels and drops back in. |
| 152 | Every fade is a filter transition, never opacity: Chromium drops a backdrop |
| 153 | filter for frames while its element fades. */ |
| 154 | [data-liquid-glass-tabs] { |
| 155 | position: relative; |
| 156 | isolation: isolate; |
| 157 | -webkit-user-select: none; |
| 158 | user-select: none; |
| 159 | } |
| 160 | /* On another glass surface the track is a recessed tint, not glass of its own. */ |
| 161 | [data-liquid-glass-tabs-on-glass] { |
| 162 | background-color: rgb(0 0 0 / 0.2); |
| 163 | box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.25); |
| 164 | } |
| 165 | [data-liquid-glass-tabs-on-glass][data-liquid-glass-tabs-appearance="light"] { |
| 166 | background-color: rgb(0 0 0 / 0.045); |
| 167 | box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.06); |
| 168 | } |
| 169 | [data-liquid-glass-tab] { |
| 170 | position: relative; |
| 171 | z-index: 1; |
| 172 | } |
| 173 | .liquid-glass-tabs-light, |
| 174 | .liquid-glass-tabs-well, |
| 175 | .liquid-glass-tabs-lens { |
| 176 | position: absolute; |
| 177 | inset: 0; |
| 178 | border-radius: inherit; |
| 179 | pointer-events: none; |
| 180 | } |
| 181 | /* The pointer lights the glass beneath it. */ |
| 182 | @property --liquid-glass-tabs-light-size { |
| 183 | syntax: "<length>"; |
| 184 | inherits: false; |
| 185 | initial-value: 56px; |
| 186 | } |
| 187 | .liquid-glass-tabs-light { |
| 188 | z-index: -1; |
| 189 | --liquid-glass-tabs-light-size: 56px; |
| 190 | -webkit-mask-image: radial-gradient(circle var(--liquid-glass-tabs-light-size) at var(--liquid-glass-tabs-x, -999px) var(--liquid-glass-tabs-y, -999px), #000, transparent); |
| 191 | mask-image: radial-gradient(circle var(--liquid-glass-tabs-light-size) at var(--liquid-glass-tabs-x, -999px) var(--liquid-glass-tabs-y, -999px), #000, transparent); |
| 192 | transition: backdrop-filter 240ms ease-out, --liquid-glass-tabs-light-size 300ms ease-out; |
| 193 | } |
| 194 | [data-liquid-glass-tabs-pointer] > .liquid-glass-tabs-light { |
| 195 | -webkit-backdrop-filter: brightness(1.3) saturate(1.15); |
| 196 | backdrop-filter: brightness(1.3) saturate(1.15); |
| 197 | } |
| 198 | [data-liquid-glass-tabs-appearance="light"][data-liquid-glass-tabs-pointer] > .liquid-glass-tabs-light { |
| 199 | -webkit-backdrop-filter: brightness(1.05) saturate(1.1); |
| 200 | backdrop-filter: brightness(1.05) saturate(1.1); |
| 201 | } |
| 202 | /* As iOS's tab bar does, a press brightens the glass under it, and letting go |
| 203 | the light spreads across the bar as it fades. */ |
| 204 | [data-liquid-glass-tabs-press] > .liquid-glass-tabs-light { |
| 205 | --liquid-glass-tabs-light-size: 40px; |
| 206 | -webkit-backdrop-filter: brightness(1.6) saturate(1.2); |
| 207 | backdrop-filter: brightness(1.6) saturate(1.2); |
| 208 | transition: backdrop-filter 100ms ease-out, --liquid-glass-tabs-light-size 100ms ease-out; |
| 209 | } |
| 210 | [data-liquid-glass-tabs-appearance="light"][data-liquid-glass-tabs-press] > .liquid-glass-tabs-light { |
| 211 | -webkit-backdrop-filter: brightness(1.05) saturate(1.15); |
| 212 | backdrop-filter: brightness(1.05) saturate(1.15); |
| 213 | } |
| 214 | [data-liquid-glass-tabs-release] > .liquid-glass-tabs-light { |
| 215 | --liquid-glass-tabs-light-size: 160px; |
| 216 | -webkit-backdrop-filter: brightness(1); |
| 217 | backdrop-filter: brightness(1); |
| 218 | transition: backdrop-filter 500ms ease-out, --liquid-glass-tabs-light-size 500ms cubic-bezier(0.23, 1, 0.32, 1); |
| 219 | } |
| 220 | @media (prefers-reduced-motion: reduce) { |
| 221 | [data-liquid-glass-tabs-press] > .liquid-glass-tabs-light, |
| 222 | [data-liquid-glass-tabs-release] > .liquid-glass-tabs-light { |
| 223 | --liquid-glass-tabs-light-size: 56px; |
| 224 | } |
| 225 | } |
| 226 | /* Hovering another tab brightens its patch of glass, as the well does. */ |
| 227 | [data-liquid-glass-tab]::before { |
| 228 | content: ""; |
| 229 | position: absolute; |
| 230 | inset: 0; |
| 231 | z-index: -1; |
| 232 | border-radius: inherit; |
| 233 | pointer-events: none; |
| 234 | transition: backdrop-filter 150ms ease-out; |
| 235 | } |
| 236 | [data-liquid-glass-tab]:not([data-liquid-glass-tab-selected]):hover::before { |
| 237 | -webkit-backdrop-filter: brightness(1.2); |
| 238 | backdrop-filter: brightness(1.2); |
| 239 | } |
| 240 | [data-liquid-glass-tabs-appearance="light"] [data-liquid-glass-tab]:not([data-liquid-glass-tab-selected]):hover::before { |
| 241 | -webkit-backdrop-filter: brightness(0.96); |
| 242 | backdrop-filter: brightness(0.96); |
| 243 | } |
| 244 | /* The selected tab can be dragged across the others. */ |
| 245 | [data-liquid-glass-tabs="horizontal"] [data-liquid-glass-tab-selected] { |
| 246 | touch-action: pan-y; |
| 247 | } |
| 248 | /* Under the labels at rest, over them while it is a lens (data-raised), so it can |
| 249 | bend and magnify them. It goes back under them as it finishes dropping in, by |
| 250 | when it no longer magnifies. Its shadow grows with the lift |
| 251 | (--liquid-glass-tabs-lift, 0 to 1). */ |
| 252 | .liquid-glass-tabs-thumb { |
| 253 | position: absolute; |
| 254 | top: 0; |
| 255 | left: 0; |
| 256 | z-index: 0; |
| 257 | pointer-events: none; |
| 258 | box-shadow: |
| 259 | 0 8px 20px -6px rgb(0 0 0 / calc(0.35 * var(--liquid-glass-tabs-lift, 0))), |
| 260 | 0 2px 6px -2px rgb(0 0 0 / calc(0.15 * var(--liquid-glass-tabs-lift, 0))); |
| 261 | } |
| 262 | .liquid-glass-tabs-thumb:not([data-shown]) { |
| 263 | visibility: hidden; |
| 264 | } |
| 265 | .liquid-glass-tabs-thumb[data-raised] { |
| 266 | z-index: 2; |
| 267 | } |
| 268 | [data-liquid-glass-tabs-appearance="light"] > .liquid-glass-tabs-thumb { |
| 269 | box-shadow: |
| 270 | 0 8px 20px -6px rgb(0 0 0 / calc(0.18 * var(--liquid-glass-tabs-lift, 0))), |
| 271 | 0 2px 6px -2px rgb(0 0 0 / calc(0.08 * var(--liquid-glass-tabs-lift, 0))); |
| 272 | } |
| 273 | /* The selected tab is a well in the glass: brighter glass in dark, dimmer in |
| 274 | light, with nothing painted over it. It clears as the lens lifts and refills |
| 275 | after it drops, by --liquid-glass-tabs-platter (1 at rest, 0 clear), and the |
| 276 | pointer over it brightens it further. Safari ignores custom properties in |
| 277 | -webkit-backdrop-filter, so it keeps the resting values. */ |
| 278 | .liquid-glass-tabs-well { |
| 279 | -webkit-backdrop-filter: brightness(1.45); |
| 280 | backdrop-filter: brightness(calc(1 + 0.45 * var(--liquid-glass-tabs-platter, 1))); |
| 281 | box-shadow: 0 1px 3px rgb(0 0 0 / calc(0.3 * var(--liquid-glass-tabs-platter, 1))); |
| 282 | transition: backdrop-filter 180ms ease-out, box-shadow 180ms ease-out; |
| 283 | } |
| 284 | [data-liquid-glass-tabs-hover] > .liquid-glass-tabs-thumb > .liquid-glass-tabs-well { |
| 285 | -webkit-backdrop-filter: brightness(1.7); |
| 286 | backdrop-filter: brightness(calc(1 + 0.7 * var(--liquid-glass-tabs-platter, 1))); |
| 287 | } |
| 288 | [data-liquid-glass-tabs-appearance="light"] > .liquid-glass-tabs-thumb > .liquid-glass-tabs-well { |
| 289 | -webkit-backdrop-filter: brightness(0.92); |
| 290 | backdrop-filter: brightness(calc(1 - 0.08 * var(--liquid-glass-tabs-platter, 1))); |
| 291 | box-shadow: none; |
| 292 | } |
| 293 | [data-liquid-glass-tabs-appearance="light"][data-liquid-glass-tabs-hover] > .liquid-glass-tabs-thumb > .liquid-glass-tabs-well { |
| 294 | -webkit-backdrop-filter: brightness(0.86); |
| 295 | backdrop-filter: brightness(calc(1 - 0.14 * var(--liquid-glass-tabs-platter, 1))); |
| 296 | } |
| 297 | /* Moving, those values change every frame; a transition would only lag them. */ |
| 298 | .liquid-glass-tabs-thumb[data-moving] > .liquid-glass-tabs-well { |
| 299 | transition: none; |
| 300 | } |
| 301 | /* The thumb is a clear lens over the well: the rim bends what is beneath, and the |
| 302 | body only lifts it a little. At rest that is the track under it; lifted, the labels. */ |
| 303 | .liquid-glass-tabs-lens { |
| 304 | isolation: isolate; |
| 305 | z-index: 1; |
| 306 | } |
| 307 | .liquid-glass-tabs-lens .liquid-glass-layer { |
| 308 | transition: backdrop-filter 180ms ease-out; |
| 309 | } |
| 310 | .liquid-glass-tabs-thumb[data-moving] .liquid-glass-tabs-lens .liquid-glass-layer { |
| 311 | transition: none; |
| 312 | } |
| 313 |
| 314 | /* With more contrast asked for, the sides darken and the edge becomes the |
| 315 | contrasting border Apple gives glass under Increase Contrast. */ |
| 316 | @media (prefers-contrast: more) { |
| 317 | .liquid-glass[data-appearance="dark"] > [data-layer="shade"] { |
| 318 | -webkit-backdrop-filter: brightness(0.1) !important; |
| 319 | backdrop-filter: brightness(0.1) !important; |
| 320 | } |
| 321 | .liquid-glass[data-appearance="light"] > [data-layer="shade"] { |
| 322 | -webkit-backdrop-filter: brightness(0.6) !important; |
| 323 | backdrop-filter: brightness(0.6) !important; |
| 324 | } |
| 325 | .liquid-glass[data-appearance="dark"] > [data-layer="edge"] { |
| 326 | -webkit-backdrop-filter: brightness(0.3) !important; |
| 327 | backdrop-filter: brightness(0.3) !important; |
| 328 | } |
| 329 | .liquid-glass[data-appearance="light"] > [data-layer="edge"] { |
| 330 | -webkit-backdrop-filter: brightness(0.5) !important; |
| 331 | backdrop-filter: brightness(0.5) !important; |
| 332 | } |
| 333 | } |
| 334 | @media (prefers-reduced-transparency: reduce) { |
| 335 | .liquid-glass { |
| 336 | display: none; |
| 337 | } |
| 338 | [data-liquid-glass] { |
| 339 | background: var(--liquid-glass-solid); |
| 340 | } |
| 341 | [data-liquid-glass] .liquid-glass-well { |
| 342 | -webkit-backdrop-filter: none; |
| 343 | backdrop-filter: none; |
| 344 | background: rgb(255 255 255 / 0.05); |
| 345 | } |
| 346 | .liquid-glass-tabs-light, |
| 347 | .liquid-glass-tabs-lens { |
| 348 | display: none; |
| 349 | } |
| 350 | [data-liquid-glass-tab]::before { |
| 351 | content: none; |
| 352 | } |
| 353 | /* Over the light and hover wells, which are more specific. */ |
| 354 | [data-liquid-glass-tabs] > .liquid-glass-tabs-thumb > .liquid-glass-tabs-well { |
| 355 | -webkit-backdrop-filter: none !important; |
| 356 | backdrop-filter: none !important; |
| 357 | background-color: rgb(255 255 255 / 0.1); |
| 358 | } |
| 359 | } |
| 360 | @media (forced-colors: active) { |
| 361 | .liquid-glass { |
| 362 | display: none; |
| 363 | } |
| 364 | .liquid-glass-tabs-well { |
| 365 | outline: 1px solid CanvasText; |
| 366 | } |
| 367 | } |
| 368 |