Nine slats, each opening with the same progress.
Cookbook
Short recipes for common needs. Each one keeps the native element in charge.
- One renderer for the whole page
- Make a popover or dialog appear from nothing
- Fade and blur the content with
--p - Soften the clip edge of a details
- Your own effect in twenty lines
- Choose the dialog layout
- Test the fallbacks
- Clean up in a single-page app
1. One renderer for the whole page
Browsers limit the number of WebGL contexts. Create one renderer and pass it to every element, whatever effect each one uses: they share one canvas and one context, and only one animation runs at a time.
const renderer = createRenderer()
createDetailsFx(faq, { renderer, effect: inkEffect })
createDialogFx(confirm, { renderer, effect: inkVeilEffect, layout: 'behind' })
createPopoverFx(menu, { renderer, effect: wipeEffect })
To create it lazily, pass a function: { renderer: () => getRenderer() }.
2. Make a popover or dialog appear from nothing
A cover in the surface color only hides the content: the box shows up complete, then its content appears. With layout: 'behind' the canvas sits behind the content and a veil effect paints the surface itself, so the box goes from invisible to visible through the ink, and the page shows through until the ink gets there.
I came out of nothing.
createPopoverFx(menu, { renderer, effect: inkVeilEffect, layout: 'behind' })
createDialogFx(confirm, { renderer, effect: inkVeilEffect, layout: 'behind' })
.pop, dialog { --fx-cover: 251, 248, 240; } /* the sheet color the shader paints */
.pop[data-fx-behind][data-animating] { background: transparent; }
.pop[data-animating] > :not(canvas) { opacity: var(--p, 1); }
For a dialog, dialog.css already makes the panel transparent while animating and fades the backdrop.
3. Fade and blur the content with --p
Elements expose the shader's progress as --p. CSS can follow it. Keep the canvas out of the selector, and change the blur radius in whole steps: every new radius re-rasterizes the text.
details[data-animating] .content > :not(canvas),
[popover][data-animating] > :not(canvas) {
opacity: var(--p, 1);
filter: blur(round(up, calc(max(0, 1 - var(--p, 1) * 1.5) * 8px), 2px));
}
4. Soften the clip edge of a details
While it opens, a details clips its content at a growing height, which cuts the effect with a hard line. The visible part is exactly the top --p fraction of the content, so a mask can fade that edge; the band shrinks to nothing at the end.
details[data-animating] .content {
--band: min(3.5rem, calc((1 - var(--p, 1)) * 14rem));
mask-image: linear-gradient(to bottom,
#000 calc(var(--p, 1) * 100% - var(--band)),
transparent calc(var(--p, 1) * 100%));
}
5. Your own effect in twenty lines
Venetian blinds. The source below is the one running in the example.
Blinds
import { defineGlslEffect } from 'webgl-ui'
const blindsEffect = defineGlslEffect('blinds', BLINDS_FRAG)
createDetailsFx(details, { renderer, effect: blindsEffect })
Rules: write premultiplied alpha; at uProgress = 1 a cover effect must be fully transparent; keep the luminance from flashing more than three times per second (WCAG 2.3.1).
6. Choose the dialog layout
viewport (default) | host | |
|---|---|---|
| The effect paints | the backdrop, behind the panel | over the dialog box (host) or the panel's sheet behind its content (behind) |
| The panel | fades in with --p | appears from the effect |
| The backdrop | the shader, then CSS | CSS, fading in with --p |
| Effects | inkVeilEffect, backdropEffect | inkVeilEffect (behind), inkEffect (host), … |
7. Test the fallbacks
Nothing to write: without WebGL2, after a lost context, or with reduced motion, forced colors, more contrast or reduced transparency, elements are native. To check it with Playwright:
// reduced motion, forced colors, more contrast
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.emulateMedia({ forcedColors: 'active' })
await page.emulateMedia({ contrast: 'more' })
// no WebGL2
await page.addInitScript(() => {
const original = HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
return type === 'webgl2' ? null : original.call(this, type, ...args)
}
})
8. Clean up in a single-page app
const fx = createDetailsFx(details, { renderer, effect: inkEffect })
// on unmount
fx.destroy() // settles any running animation, restores native methods
// when the whole app goes away
renderer.dispose() // frees the WebGL context