Cookbook

Short recipes for common needs. Each one keeps the native element in charge.

  1. One renderer for the whole page
  2. Make a popover or dialog appear from nothing
  3. Fade and blur the content with --p
  4. Soften the clip edge of a details
  5. Your own effect in twenty lines
  6. Choose the dialog layout
  7. Test the fallbacks
  8. 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.

Live example

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.

Live example
Blinds

Nine slats, each opening with the same progress.

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 paintsthe backdrop, behind the panelover the dialog box (host) or the panel's sheet behind its content (behind)
The panelfades in with --pappears from the effect
The backdropthe shader, then CSSCSS, fading in with --p
EffectsinkVeilEffect, backdropEffectinkVeilEffect (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