# Glass and glow

> Frosted glass that blurs and bends what is behind it, and neon light that adds up to white, from the pixi-silk/glass and pixi-silk/glow entry points.

Source: https://pixi-silk.schmooky.dev/docs/glass-and-glow/

`pixi-silk/glass` draws panes of frosted glass over your scene. `pixi-silk/glow` stacks blurred
passes of one shape into neon light. Both are separate entry points, not part of the root import.

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `glass-playground` (source):

```ts
import { Container, Text } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { Glass } from 'pixi-silk/glass';
import { trackGesture } from 'pixi-silk/ui/gesture';
import { defineDemo, FONT, PAD, SERIES } from './runtime';

const BLOBS = SERIES.slice(0, 5);

export default defineDemo({
    title: 'Glass playground',
    size: [720, 460],
    // phones: the words stack and the pane is narrower
    phone: { size: [360, 460] },
    touchAction: 'none',
    controls: {
        blur: { type: 'range', label: 'blur', min: 0, max: 48, step: 1, value: 18 },
        refraction: { type: 'range', label: 'refraction', min: -30, max: 40, step: 1, value: 16 },
        dispersion: { type: 'range', label: 'dispersion', min: 0, max: 1, step: 0.01, value: 0.4 },
        frost: { type: 'range', label: 'frost', min: 0, max: 0.6, step: 0.01, value: 0.06 },
        light: { type: 'range', label: 'light', min: -180, max: 180, step: 1, value: -135, unit: '°' },
        grain: { type: 'range', label: 'grain', min: 0, max: 0.15, step: 0.005, value: 0.035 },
    },
    setup({ stage, params, tick, width, height, phone }) {
        const back = new SilkGraphics();
        const blobs = new SilkGraphics();
        const lines = new SilkGraphics();
        const words = new Container();
        const glass = new Glass({ width: phone ? 260 : 300, height: 190, radius: 34 });
        const hint = new Text({
            text: 'drag me',
            style: { fontFamily: FONT, fontSize: 14, fill: 0xffffff, fontWeight: '600' },
            alpha: 0.85,
        });
        let grab: { x: number; y: number } | null = null;

        blobs.blendMode = 'add';
        back.rect(0, 0, width, height).fill({ color: 0x000000, alpha: 0.001 });
        for (let i = 0; i < 9; i++) lines.rect(0, 40 + i * 48, width, 1.5).fill({ color: 0xffffff, alpha: 0.18 });
        // three words spread across the width, inside the padding, or one under the other on phones
        const texts = ['PIXI', 'SILK', 'GLASS'].map(
            (w) => new Text({ text: w, style: { fontFamily: FONT, fontSize: 88, fontWeight: '800', fill: 0xffffff } }),
        );
        const gap = (720 - 2 * PAD - texts.reduce((sum, t) => sum + t.width, 0)) / 2;
        let x = PAD;

        texts.forEach((t, i) => {
            t.alpha = 0.9;
            if (phone) t.position.set(i === 1 ? width - PAD - t.width : PAD, 60 + i * 115);
            else t.position.set(x, 130 + (i % 2) * 90);
            x += t.width + gap;
            words.addChild(t);
        });
        hint.anchor.set(0.5);
        hint.position.set(phone ? 130 : 150, 95);
        glass.content.addChild(hint);
        if (phone) glass.position.set(50, 135);
        else glass.position.set(210, 135);
        glass.eventMode = 'static';
        glass.cursor = 'grab';
        stage.addChild(back, blobs, lines, words, glass);

        trackGesture(
            glass,
            {
                onDown: (p) => (grab = { x: p.x - glass.x, y: p.y - glass.y }),
                onDrag: (p) => grab && glass.position.set(p.x - grab.x, p.y - grab.y),
                onUp: () => (grab = null),
            },
            { space: stage },
        );

        tick((t) => {
            blobs.clear();
            BLOBS.forEach((c, i) => {
                const a = t * (0.25 + i * 0.05) + i * 1.3;

                blobs
                    .circle(
                        width / 2 + Math.cos(a) * ((phone ? 110 : 220) - i * 18),
                        height / 2 + Math.sin(a * 1.3) * 130,
                        90 - i * 6,
                    )
                    .fill({ color: c, alpha: 0.9, blur: 40 });
            });
            glass.set({
                blur: params.blur,
                refraction: params.refraction,
                dispersion: params.dispersion,
                frost: params.frost,
                lightAngle: (params.light * Math.PI) / 180,
                grain: params.grain,
            });
        });

        return undefined;
    },
});
```

```ts

const card = new Glass({ width: 300, height: 190, radius: 34, blur: 18, refraction: 16, dispersion: 0.4 });

card.content.addChild(new Text({ text: 'Frosted', style: { fill: 0xffffff } }));
card.set({ lightAngle: -Math.PI / 4, grain: 0.05 });
app.stage.addChild(card); // after the scene it frosts
```

The pane reads whatever is drawn behind it, blurs it and bends it along the curved rim. Labels and
icons go in `card.content`, which draws on top of the glass and stays sharp. `set()` changes
options later.

## Glass options

| Option | Default | What it does |
| --- | --- | --- |
| `width`, `height` | 240, 160 | Pane size. The top-left corner is at (0, 0). |
| `radius` | 28 | Corner radius |
| `smoothing` | 0.6 | Squircle corners, 0 (circular) to 1 |
| `blur` | 18 | Backdrop blur radius |
| `refraction` | 14 | How far the rim bends the backdrop. Negative bends inwards. |
| `dispersion` | 0.35 | Colour split at the rim, 0 to 1 |
| `bevel` | 22 | Width of the curved rim |
| `frost` | 0.06 | Milky whitening, 0 to 1 |
| `saturation` | 1.2 | Colour boost of what shows through |
| `tint`, `tintAmount` | white, 0.04 | Coloured glass |
| `opacity` | 1 | Overall opacity |
| `lightAngle`, `light` | top left, 0.8 | Where the sheen comes from, in radians, and how bright it is |
| `grain` | 0.035 | Film grain |
| `shadow` | 0.3 | Soft shadow under the pane. 0 turns it off. |

## Where glass stops

The pane needs the canvas behind it. On WebGL, `Glass` turns on the renderer's back buffer the
first time it draws, so the first frame shows a plain pane. Pass `useBackBuffer: true` to
`createSilkApp` to have glass from the first frame. WebGPU reads the backdrop without a back
buffer. On any other renderer the pane draws a plain frosted fill.

Add the pane after the scene it frosts, since it only sees what is already drawn. You can rotate
and scale it. Under an uneven scale, the corners stay round with the radius of the smaller scale.
The light stays fixed on screen. Skew is not supported, and the pane keeps its unskewed shape.

## Make neon with Glow

Live demo `glow-neon` (source):

```ts
import { Container, Text } from 'pixi.js';
import { linear, radial, SilkGraphics } from 'pixi-silk';
import { bolt, Glow, neon } from 'pixi-silk/glow';
import { defineDemo } from './runtime';

const YELLOW = 0xffd60a;
const font = 'Inter, system-ui, sans-serif';

export default defineDemo({
    title: 'Neon bolt',
    size: [720, 440],
    // phones: the big bolt above the card
    phone: { size: [360, 600] },
    background: 0x0b0b09,
    controls: {
        intensity: { type: 'range', label: 'glow', min: 0, max: 2, step: 0.05, value: 1 },
    },
    setup({ stage, params, tick, phone }) {
        const room = new SilkGraphics();

        // warm light spilling into the room, like the reference backdrop
        // taller than the canvas, so a wide frame shows no band above or below
        room.rect(0, -220, 720, 1000).fill(
            radial(
                [
                    [0, 0x3a3410, 1],
                    [0.55, 0x15140c, 1],
                    [1, 0x0b0b09, 1],
                ],
                { center: [0.28, 0.45], radius: 0.75 },
            ),
        );

        // the bolt alone: about 120 wide, so the recipe runs at size 120
        const big = new Glow({ draw: (g) => bolt(g, -60, -84, 120, 168), layers: neon(YELLOW, { size: 120 }) });

        if (phone) big.position.set(180, 110);
        else big.position.set(180, 220);

        // the ticket card
        const card = new Container();
        const back = new SilkGraphics();
        const ticket = new SilkGraphics();
        const ring = new SilkGraphics();
        const small = new Glow({ draw: (g) => bolt(g, -52, -74, 104, 148), layers: neon(YELLOW, { size: 104 }) });

        // translucent yellow folder behind the ticket
        back.roundRect(0, 0, 230, 250, 18, 0.6)
            .fill({ color: 0x000000, alpha: 0.6, blur: 30, spread: 6 })
            .roundRect(0, 0, 230, 250, 18, 0.6)
            .fill({
                gradient: linear(
                    [
                        [0, 0x8a7d1c, 0.95],
                        [1, 0x4f4710, 0.95],
                    ],
                    { from: [0, 0], to: [0, 1] },
                ),
                innerShadow: { color: 0xfff6b0, alpha: 0.25, y: 1, blur: 1 },
            });
        small.position.set(118, 50);
        // the white ticket with a notch where the ring sits
        ticket
            .beginPath()
            .moveTo(14, 160)
            .lineTo(146, 160)
            .bezierCurveTo(160, 160, 160, 176, 176, 176)
            .lineTo(244, 176)
            .bezierCurveTo(252, 176, 258, 182, 258, 190)
            .lineTo(258, 300)
            .bezierCurveTo(258, 308, 252, 314, 244, 314)
            .lineTo(14, 314)
            .bezierCurveTo(6, 314, 0, 308, 0, 300)
            .lineTo(0, 174)
            .bezierCurveTo(0, 166, 6, 160, 14, 160)
            .closePath()
            .fill({ color: 0x000000, alpha: 0.55, blur: 18, spread: 2 })
            .fill({
                gradient: linear(
                    [
                        [0, 0xf4f5f0, 1],
                        [1, 0xc9ccd2, 1],
                    ],
                    { from: [0, 0], to: [0.3, 1] },
                ),
                innerShadow: { color: 0xffffff, alpha: 0.9, y: 1, blur: 0.5 },
            });
        for (let i = 0; i < 46; i++) {
            const w = [1, 2, 1, 3, 1, 2][i % 6];

            ticket.rect(22 + i * 4.6, 284, w, 12).fill({ color: 0x1d2a3a, alpha: 0.85 });
        }
        const title = new Text({
            text: 'Supercharged', // prose-ignore: copy from the design reference
            style: { fontFamily: font, fontSize: 23, fontWeight: '700', fill: 0x18181b },
        });
        const sub = new Text({
            text: 'Experience network speed\nlike never before',
            style: { fontFamily: font, fontSize: 12.5, fill: 0x52525b, lineHeight: 17 },
        });

        title.position.set(22, 222);
        sub.position.set(22, 250);
        ring.position.set(206, 172);
        card.addChild(back, small, ticket, ring, title, sub);
        if (phone) card.position.set(51, 250);
        else card.position.set(414, 62);
        stage.addChild(room, big, card);

        let last = -1;

        tick((t) => {
            const flicker = 0.92 + 0.08 * Math.sin(t * 7.3) * Math.sin(t * 2.1);

            if (params.intensity !== last) {
                last = params.intensity;
                big.setLayers(neon(YELLOW, { size: 120, intensity: params.intensity }));
                small.setLayers(neon(YELLOW, { size: 104, intensity: params.intensity }));
            }
            big.alpha = flicker;
            ring.clear()
                .circle(0, 0, 30)
                .stroke({ width: 12, color: YELLOW, alpha: 0.75 * params.intensity, blur: 9, blend: 'add' })
                .circle(0, 0, 30)
                .stroke({ width: 3.5, color: 0xfff27a, blur: 0.8 })
                .circle(0, 0, 30)
                .stroke({ width: 2, color: 0xffffff, alpha: 0.9, blur: 3, blend: 'add' });
        });

        return undefined;
    },
});
```

```ts

const spark = new Glow({
    draw: (g) => bolt(g, -50, -70, 100, 140),
    layers: neon(0xffd60a, { size: 100 }),
});
```

`Glow` draws one shape several times, once per layer, each with its own fill, stroke, blend and
offset. `neon()` returns the five-layer recipe for a shape about `size` wide. Two blooms go behind
it. The core has a gradient, coloured drop glows and a pale inner shadow. A white copy added on top
burns the middle out, and a blurred white outline finishes it.

| `neon()` option | Default | What it does |
| --- | --- | --- |
| `size` | 100 | Rough size of the shape. Every blur scales with it. |
| `intensity` | 1 | Strength of the bloom, 0 to about 2 |
| `core` | a radial gradient | Paint of the solid core |
| `surface` | `'dark'` | On `'light'` surfaces the passes paint normally, since added light would vanish on white |

`bolt()` and `drop()` add ready outlines. Any path works in `draw`, because a fill treats a path
as one shape.

## Light that adds up

Light does not cover what is under it, it adds to it. Set `blendMode = 'add'` on a graphics, or
`blend: 'add'` on one fill, and overlapping glows burn towards white. Figma calls this Plus
lighter. With a `blur` on the fill, a soft light is one ellipse.

Live demo `glow-button` (source):

```ts
import { Container, Text } from 'pixi.js';
import { along, linear, radial, SilkGraphics } from 'pixi-silk';
import { trackGesture } from 'pixi-silk/ui/gesture';
import { defineDemo } from './runtime';

const W = 400;
const H = 128;

/** Point at `u` (0..1) around a pill centred at (0, 0), clockwise from the middle of its right end. */
function onPill(u: number): [number, number] {
    const r = H / 2;
    const straight = W - H;
    const total = 2 * straight + 2 * Math.PI * r;
    let d = (((u % 1) + 1) % 1) * total;

    if (d < Math.PI * r * 0.5) {
        const a = d / r;

        return [straight / 2 + Math.cos(a) * r, Math.sin(a) * r];
    }
    d -= Math.PI * r * 0.5;
    if (d < straight) return [straight / 2 - d, r];
    d -= straight;
    if (d < Math.PI * r) {
        const a = Math.PI / 2 + d / r;

        return [-straight / 2 + Math.cos(a) * r, Math.sin(a) * r];
    }
    d -= Math.PI * r;
    if (d < straight) return [-straight / 2 + d, -r];
    d -= straight;
    const a = -Math.PI / 2 + d / r;

    return [straight / 2 + Math.cos(a) * r, Math.sin(a) * r];
}

/** The border as an open path: it starts at `u0` and stops short of closing, leaving a gap. */
function border(u0: number, gap: number): number[] {
    const out: number[] = [];
    const n = 220;

    for (let i = 0; i <= n; i++) out.push(...onPill(u0 + (i / n) * (1 - gap)));

    return out;
}

export default defineDemo({
    title: 'Glow button',
    size: [620, 360],
    background: 0x05061a,
    touchAction: 'none',
    setup({ stage, tick }) {
        const room = new SilkGraphics();
        const button = new Container();
        const bloom = new SilkGraphics();
        const body = new SilkGraphics();
        const line = new SilkGraphics();
        const dust = new SilkGraphics();
        const label = new Text({
            text: 'Magic button',
            style: { fontFamily: 'Inter, system-ui, sans-serif', fontSize: 34, fontWeight: '500', fill: 0xe4e2f4 },
        });
        // violet on the long run of the border, cooling to teal at its head
        const tube = along([
            [0, 0xffffff, 0],
            [0.08, 0xd9c8ff, 1],
            [0.55, 0xffffff, 1],
            [0.85, 0xc8f4ff, 1],
            [1, 0x7cf7e8, 0.9],
        ]);
        const halo = along([
            [0, 0x8b3dff, 0],
            [0.15, 0x9b4dff, 1],
            [0.5, 0x6d5bff, 1],
            [0.8, 0x3d8bff, 1],
            [1, 0x2ee6d6, 1],
        ]);
        const dots = Array.from({ length: 70 }, (_, i) => ({
            x: (((i * 0.618034) % 1) - 0.5) * 340,
            y: ((((i * 0.7548777 + 0.31) * 7) % 1) - 0.5) * 90,
            r: 0.6 + ((i * 31) % 10) / 10,
            p: i * 1.37,
        }));
        let hover = 0;
        let hot = 0;
        let burst = 0;
        let head = 0;

        room.rect(0, 0, 620, 360).fill(
            radial(
                [
                    [0, 0x1a1060, 1],
                    [0.6, 0x0a0a2c, 1],
                    [1, 0x05061a, 1],
                ],
                { center: [0.45, 0.5], radius: 0.8 },
            ),
        );
        bloom.blendMode = 'add';
        line.blendMode = 'add';
        dust.blendMode = 'add';
        label.anchor.set(0.5);
        button.position.set(310, 180);
        button.addChild(bloom, body, dust, line, label);
        button.eventMode = 'static';
        button.cursor = 'pointer';
        button.hitArea = { contains: (x, y) => Math.abs(x) <= W / 2 && Math.abs(y) <= H / 2 };
        button.on('pointerover', () => (hot = 1));
        button.on('pointerout', () => (hot = 0));
        trackGesture(button, { onTap: () => (burst = 1) });
        stage.addChild(room, button);

        body.pill(-W / 2, -H / 2, W, H).fill({
            gradient: linear(
                [
                    [0, 0x1d1a8a, 1],
                    [1, 0x0f1150, 1],
                ],
                { from: [0, 0], to: [0, 1] },
            ),
            innerShadow: [
                { color: 0x000000, alpha: 0.45, y: 18, blur: 22, spread: 4 },
                { color: 0x8f7bff, alpha: 0.35, y: -2, blur: 6, blend: 'add' },
            ],
        });

        tick((t, dt) => {
            hover += (hot - hover) * Math.min(1, dt * 8);
            burst = Math.max(0, burst - dt * 1.4);
            head += dt * (0.05 + hover * 0.12 + burst * 0.4);
            const glow = 0.8 + hover * 0.3 + burst * 0.7;
            const pts = border(head, 0.035);

            bloom
                .clear()
                .pill(-W / 2, -H / 2, W, H)
                .fill({ color: 0x5b3dff, alpha: 0.35 * glow, blur: 60, spread: 10 })
                .polyline(pts)
                .stroke({ width: 26, gradient: halo, alpha: 0.75 * glow, blur: 16 });
            line.clear()
                .polyline(pts)
                .stroke({ width: 12, gradient: halo, alpha: glow, blur: 4 })
                .polyline(pts)
                .stroke({ width: 5, gradient: tube, cap: 'round' })
                .polyline(pts)
                .stroke({ width: 3, gradient: tube, alpha: 0.6, blur: 1.5 });
            // a star of light sits on the head of the border
            const [hx, hy] = onPill(head + 0.965);

            line.circle(hx, hy, 18)
                .fill({ color: 0xbffff6, alpha: 0.5 * glow, blur: 14 })
                .star(hx, hy, 4, 28 * glow, 1.2, 0, 0)
                .fill({ color: 0xffffff, alpha: 0.5, blur: 2 });
            dust.clear();
            for (const d of dots) {
                const a = (0.25 + 0.75 * Math.max(0, Math.sin(t * 1.6 + d.p)) ** 4) * (0.5 + hover * 0.5 + burst);

                dust.circle(d.x, d.y, d.r).fill({ color: 0xc7d2ff, alpha: Math.min(1, a * 0.7), blur: 0.4 });
            }
            button.scale.set(1 + burst * 0.03);
        });

        return undefined;
    },
});
```

The border of this button is an open path. Its head leaves a gap with a star of light in it.

Live demo `glow-icons` (source):

```ts
import { Container } from 'pixi.js';
import { linear, radial, SilkGraphics } from 'pixi-silk';
import { bolt, drop } from 'pixi-silk/glow';
import { defineDemo } from './runtime';

const S = 176;

interface Palette {
    lead: number;
    mid: number;
    deep: number;
    rim: number;
    accent: number;
}

const PALETTES: Record<string, Palette> = {
    violet: { lead: 0x56d8ff, mid: 0x5636ff, deep: 0x2a0d73, rim: 0xc8f6ff, accent: 0xff4060 },
    sunset: { lead: 0xffe45c, mid: 0xff5a36, deep: 0xc0129a, rim: 0xfff4c2, accent: 0xb43cff },
    ice: { lead: 0x8af4ff, mid: 0x2d7dff, deep: 0x5b2bff, rim: 0xeaffff, accent: 0x7c4dff },
};

type Draw = (g: SilkGraphics) => SilkGraphics;

/**
 * A big shape that runs off the tile, the direction its light travels (lead edge to trailing side)
 * and how far along its box the colour has faded out.
 */
const ICONS: [Draw, angle: number, reach: number][] = [
    // bolt, cut by the top and right edges, lit from its left notch
    [(g) => bolt(g, 34, -46, 168, 236), 0, 1],
    // a dome rising from the bottom right corner, lit along its rim
    [(g) => g.circle(S * 1.02, S * 1.04, S * 0.8), Math.PI / 4, 0.5],
    // a drop sitting on the bottom edge, lit from the left
    [(g) => drop(g, S * 0.5, S * 1.02, S * 0.4, S * 0.66), -Math.PI / 6, 1],
    // a zigzag wall, lit along its left edge
    [
        (g) =>
            g.poly([S * 0.96, -10, S * 0.3, S * 0.4, S * 0.74, S * 0.6, S * 0.5, S + 10, S + 10, S + 10, S + 10, -10]),
        0,
        0.8,
    ],
];

export default defineDemo({
    title: 'Glowing icons',
    size: [440, 440],
    background: 0x08080a,
    controls: {
        palette: { type: 'select', label: 'palette', options: Object.keys(PALETTES), value: 'violet' },
    },
    setup({ stage, params, tick }) {
        const arts: SilkGraphics[] = [];

        ICONS.forEach((_, i) => {
            const tile = new Container();
            const plate = new SilkGraphics();
            const clip = new SilkGraphics();
            const art = new SilkGraphics();
            const rim = new SilkGraphics();

            plate.roundRect(0, 0, S, S, 42, 0.6).fill(
                radial(
                    [
                        [0, 0x16161c, 1],
                        [1, 0x0a0a0d, 1],
                    ],
                    { center: [0.3, 0.2], radius: 1.1 },
                ),
            );
            clip.roundRect(0, 0, S, S, 42, 0.6).fill(0xffffff);
            art.mask = clip;
            // a thin glassy rim on top of the art, brighter where the light is
            rim.roundRect(0.5, 0.5, S - 1, S - 1, 42, 0.6).stroke({
                width: 1.4,
                gradient: linear(
                    [
                        [0, 0xffffff, 0.32],
                        [0.5, 0xffffff, 0.06],
                        [1, 0xffffff, 0.2],
                    ],
                    { from: [0, 0], to: [1, 1] },
                ),
                alignment: 'inside',
            });
            tile.addChild(plate, art, clip, rim);
            tile.position.set(30 + (i % 2) * (S + 28), 30 + Math.floor(i / 2) * (S + 28));
            stage.addChild(tile);
            arts.push(art);
        });

        let last = '';

        tick(() => {
            if (params.palette === last) return;
            last = params.palette;
            const p = PALETTES[params.palette];

            ICONS.forEach(([draw, angle, reach], i) => {
                const art = arts[i];
                const dx = Math.cos(angle);
                const dy = Math.sin(angle);
                const from: [number, number] = [0.5 - dx * 0.5, 0.5 - dy * 0.5];
                const to: [number, number] = [0.5 + dx * 0.5, 0.5 + dy * 0.5];
                const body = linear(
                    [
                        [0, p.lead, 1],
                        [0.3 * reach, p.mid, 1],
                        [0.7 * reach, p.deep, 0.85],
                        [reach, p.deep, 0],
                    ],
                    { from, to },
                );

                art.clear();
                // bloom that spills past the lit edge
                draw(art).fill({ gradient: body, alpha: 0.45, blur: 20, spread: 6, blend: 'add' });
                // the body: crisp on the lit side, dissolving towards the trailing side
                draw(art).fill({ gradient: body, blur: { from: 0.8, to: 46 * reach, angle }, blend: 'add' });
                // the lit edge: a shadow pushed towards the trailing side leaves a bright band along the lead
                draw(art).fill({
                    color: 0x000000,
                    alpha: 0,
                    innerShadow: [
                        { color: p.rim, alpha: 0.8, x: dx * 4, y: dy * 4, blur: 1.5, blend: 'add' },
                        { color: p.lead, alpha: 0.45, x: dx * 16, y: dy * 16, blur: 9, blend: 'add' },
                    ],
                });
                // a warm or cool kick where the lit edge meets the tile
                art.circle(S * (0.5 - dx * 0.35), S * 0.98, 26).fill({
                    color: p.accent,
                    alpha: 0.35,
                    blur: 18,
                    blend: 'add',
                });
            });
        });

        return undefined;
    },
});
```

Each icon is a large shape clipped by its tile. Its blur grows from the lit edge to the far side,
and an inner shadow pushed away from the light leaves a bright rim.

`Aurora` rises light in three colours from the bottom of a box and drifts slowly. `ray()`, `fan()`
and `spectrum()` build beams and prisms. The [Showcase](/docs/showcase/) has the full scenes made
with them.
