Custom components

Write your own component in TypeScript, use it like any component in the library, and write WGSL by hand when you need to.

Every component in the library is a plain TypeScript object passed to defineShader. Yours works the same way, with no build step.

A definition has two jobs: list the props someone can change, and say what color each pixel should be. Shaders handles the rest: blend modes, opacity, masks, transforms and code export.

Building a component from primitives

The primitives are the building blocks the library's own components are made of. Each one is a word: a small function you combine with others, the way you combine CSS properties. This section builds a two-color gradient from them.

1. Name it and list its props. A prop is anything someone can change later, and each one needs a default. The transform says what kind of value it is, so a color string becomes a color and an {x, y} object becomes a point:

import {defineShader, transformColor, transformPosition} from 'shaders/std'

export const MyGradient = defineShader({
  name: 'MyGradient',
  props: {
    from: {default: {x: 0, y: 0.5}, transform: transformPosition},
    to: {default: {x: 1, y: 0.5}, transform: transformPosition},
    colorA: {default: '#ff6b6b', transform: transformColor},
    colorB: {default: '#4ecdc4', transform: transformColor},
  },
  // paint: comes next
})

2. Say what to draw. A gradient is two things. First, a field: a number per pixel saying how far along the gradient that pixel sits. Second, a palette that turns that number into a color. dist.linear gives you the field, pair is the palette, and rampOver connects them. p('name') reads one of your props.

The finished definition adds a colorSpace prop and the paint: field:

import {defineShader, p, paint, transformColor, transformColorSpace, transformPosition} from 'shaders/std'

const {rampOver, dist, pair} = paint

export const MyGradient = defineShader({
  name: 'MyGradient',
  props: {
    from: {default: {x: 0, y: 0.5}, transform: transformPosition},
    to: {default: {x: 1, y: 0.5}, transform: transformPosition},
    colorA: {default: '#ff6b6b', transform: transformColor},
    colorB: {default: '#4ecdc4', transform: transformColor},
    colorSpace: {default: 'oklch', transform: transformColorSpace, compileTime: true},
  },
  paint: rampOver(
    dist.linear({from: p('from'), to: p('to'), angle: 0}),
    pair(p('colorA'), p('colorB'), p('colorSpace')),
  ),
})

That's the whole component. For a radial gradient, swap dist.linear for dist.radial, which takes a center and radius instead of two points. For more than two colors, swap pair for stops, which reads a stops prop declared with colorStopsPropConfig().

3. Pick the field that matches what you're making. This example uses paint:, which draws from nothing. The other options:

  • effect:: filters the layer nested inside it.
  • map:: distorts the layer nested inside it.
  • shape:: draws a 2D shape.

A definition uses exactly one of these.

To find a specific word, use the primitives reference. It's grouped by what you're trying to make, and each word shows an example of where it goes.

Using your component

Pass the definition to CustomShader from your framework's package. Your props become normal attributes, so colorA="#fff" sets the colorA prop from the example above. The layer props every component has, like blendMode, opacity and maskSource, work the same as everywhere else.

Here MyGradient sits inside a <Blur>:

import {Shader, CustomShader, Blur} from 'shaders/react'
import {MyGradient} from './my-gradient'

<Shader>
  <Blur intensity={8}>
    <CustomShader src={MyGradient} colorA="#ffffff" />
  </Blur>
</Shader>

To refer to your component by name in preset JSON without passing the definition each time, call registerShader(MyGradient) once when your app starts. After that, type: 'MyGradient' resolves.

Configuring props

Each prop takes a few optional settings:

  • transform: numbers and booleans don't need one. Colors use transformColor, points use transformPosition, and a color space name uses transformColorSpace.
  • compileTime: true: recompiles the effect whenever the prop changes. Use it for a structural choice like a color space, not for anything someone drags, because each change rebuilds the effect.
  • ui: tells an editor which control to show, for example {type: 'range', min: 0, max: 1, step: 0.01}, {type: 'color'}, {type: 'position'} or {type: 'select', options}.

A prop can't share a name with a layer prop every component already has, such as opacity, blendMode, transform or children. The component would consume it first, so defineShader throws and names the prop.

Writing WGSL by hand

WGSL is the language WebGPU runs on the GPU. Build with primitives where you can. When the word you need doesn't exist yet, write the pixel math yourself in a wgsl body. Unlike the primitives, this path is stable, and it sits on the definition alongside everything else.

A body is the inside of one WGSL function that returns a vec4f color. You don't declare anything: your props are available by name, and so are uv, time, aspect, viewport and pointer.

This generator draws an animated ring:

import {defineShader, wgsl, transformColor, transformPosition} from 'shaders/std'

export const Halo = defineShader({
  name: 'Halo',
  animatedTime: {speed: 'speed'},
  props: {
    color: {default: '#ffd166', transform: transformColor},
    center: {default: {x: 0.5, y: 0.5}, transform: transformPosition},
    radius: {default: 0.6},
    speed: {default: 1},
  },
  paint: wgsl`
    let d = length((uv - center) * vec2f(aspect, 1.0)) / radius;
    let ring = 0.5 + 0.5 * cos(d * 24.0 - time * 2.0);
    return vec4f(color.rgb, ring * (1.0 - smoothstep(0.7, 1.0, d)));
  `,
})

Inside an effect:, the body can also read child, the color of the nested layer at the current pixel:

export const Invert = defineShader({
  name: 'Invert',
  props: {amount: {default: 1}},
  effect: wgsl`
    return vec4f(mix(child.rgb, 1.0 - child.rgb, amount), child.a);
  `,
})

What a body receives

  • Types: colors arrive as vec4f in linear RGB, positions as vec2f, and numbers as f32.
  • uv: runs 0–1 across the canvas, with y increasing downward. Multiply by aspect to keep circles round.
  • time: the layer's own clock. Declare animatedTime: {speed: 'speed'} and add a speed prop, and setting speed slows the clock or stops it at 0.
  • Neighbouring pixels: a filter like a blur or a ripple samples childTexture with childSampler instead of reading child.
  • CPU-only props: props that never reach the GPU, such as a URL string or a list, can't be used in a body.

Next steps