Skip to main content

Filters and Effects

Because Motion Canvas is built on top of the Browser's 2D Rendering Context, we can make use of several canvas operations that are provided by the Browser.

Filters

Filters let you apply various effects to your nodes. You can find all available filters on MDN.

Press play to preview the animation
import ...

export default makeScene2D(function* (view) {
view.fill('#141414');

const timePassed = createSignal(0);
const iconRef = createRef<Img>();
const currentEffectText = createSignal('');
yield view.add(
<>
<Img src={'/img/logo_dark.svg'} size={200} x={-200} ref={iconRef} />
<Txt
fill={'rgba(255, 255, 255, 0.6)'}
fontSize={20}
x={200}
text={() => 'Current Filter: ' + currentEffectText()}
/>
</>,
);

function* filters() {
yield currentEffectText('Blur');
yield* iconRef().filters.blur(20, 1);
yield* iconRef().filters.blur(0, 1);
yield currentEffectText('Grayscale');
yield* iconRef().filters.grayscale(1, 1);
yield* iconRef().filters.grayscale(0, 1);
yield currentEffectText('Hue');
yield* iconRef().filters.hue(360, 2);
yield currentEffectText('Contrast');
yield* iconRef().filters.contrast(0, 1);
yield* iconRef().filters.contrast(1, 1);
}

yield* all(timePassed(4, 2 * 4, linear), filters());
});

Every node has a filters property containing an array of filters that will be applied to the node. You can declare this array yourself, or use the filters property to configure individual filters. Both ways are shown in the following example:

info

Some filters, like opacity and drop-shadow, have their own dedicated properties directly on the Node, class.

Press play to preview the animation
import ...

export default makeScene2D(function* (view) {
view.fill('#141414');

const iconRef = createRef<Img>();
yield view.add(<Img src={'/img/logo_dark.svg'} size={200} ref={iconRef} />);
// Modification happens by accessing the `filters` property.
// Individual filters don't need to be initialized. If a filter you set doesn't
// exists, it will be automatically created and added to the list of filters.
// If you have multiple filters of the same type, this will only
// modify the first instance (you can use the array method for more control).
yield* iconRef().filters.blur(10, 1);
yield* iconRef().filters.blur(0, 1);
});

Keep in mind that the order in which you apply the effects does matter, as can be seen in the following example:

Press play to preview the animation
import ...

export default makeScene2D(function* (view) {
view.fontFamily('monospace').fontSize(20).fill('#141414');
view.add(<Rect size={5000} fill={'#111'} />);

const t = createSignal(0);
const saturateValue = createSignal(1);
const contrastValue = createSignal(1);

view.add(
// Left Segment
<Layout x={-300} direction={'column'} alignItems={'center'} gap={20} layout>
<Circle
size={150}
fill={'#99c47a'}
filters={[saturate(saturateValue), contrast(contrastValue)]}
/>
<Layout direction={'row'} gap={20}>
<Txt fill={'#ffa'}>saturation</Txt>
<Txt fill={'#aff'}>constrast</Txt>
</Layout>
</Layout>,
);

// Right Segment
yield view.add(
<Layout x={300} direction={'column'} alignItems={'center'} gap={20} layout>
<Circle
size={150}
fill={'#99c47a'}
filters={[contrast(contrastValue), saturate(saturateValue)]}
/>
<Layout direction={'row'} gap={20}>
<Txt fill={'#aff'}>constrast</Txt>
<Txt fill={'#ffa'}>saturation</Txt>
</Layout>
</Layout>,
);

// Center Segment
view.add(
<Layout y={-10}>
<Grid size={200} stroke={'gray'} lineWidth={1} spacing={40} />
<Grid size={200} stroke={'#333'} lineWidth={1} spacing={20} />
<Rect size={200} stroke={'gray'} lineWidth={2} />
<Txt
fill={'white'}
text={'saturation'}
rotation={-90}
x={-115}
fill={'#ffa'}
/>
<Txt fill={'white'} text={'contrast'} y={115} fill={'#aff'} />
<Txt fill={'white'} text={'1'} position={[-115, 100]} />
<Txt fill={'white'} text={'1'} position={[-100, 115]} />
<Txt fill={'white'} text={'5'} position={[-115, -90]} />
<Txt fill={'white'} text={'5'} position={[100, 115]} />
<Circle
x={() => map(-150, -100, contrastValue())}
y={() => map(150, 100, saturateValue())}
fill={'white'}
size={20}
/>
</Layout>,
);

yield t(2, 8, linear);
yield* saturateValue(5, 2);
yield* contrastValue(5, 2);
yield* waitFor(1);
yield* saturateValue(1, 2);
yield* contrastValue(1, 2);
});

Masking and composite operations

Composite operations define how the thing we draw (source) interacts with what is already on the canvas (destination). Among other things, it allows us to define complex masks. MDN has a great visualisation of all available composite operations.

You can create a mask by treating one node as the "masking" / "stencil" layer, and another node as the "value" layer. The mask layer will define if the value layer will be visible or not. The value layer will be what's actually visible in the end.

Press play to preview the animation
import ...

const ImageSource =
'https://images.unsplash.com/photo-1685901088371-f498db7f8c46';

export default makeScene2D(function* (view) {
view.fontSize(20).fill('#141414');

const valuePosition = createSignal(new Vector2(150, -30));
const maskPosition = createSignal(new Vector2(-150, -30));

const maskLayerRotation = createSignal(0);
const valueLayerRotation = createSignal(0);

const fakeMaskLayerGroup = createRef<Node>();
const fakeValueLayerGroup = createRef<Node>();

// First show fake a Mask Layer. Funnily enough, this also makes use of masking!
yield view.add(
<Node ref={fakeMaskLayerGroup} opacity={0} cache>
<Img
src="/img/logo_dark.svg"
size={200}
position={maskPosition}
rotation={maskLayerRotation}
/>
<Grid
compositeOperation={'source-in'}
stroke={'white'}
width={1000}
height={400}
spacing={5}
lineWidth={1}
/>
</Node>,
);
yield view.add(
<Node ref={fakeValueLayerGroup} opacity={0} cache>
{/*
We do not specifically need to use the Image here, a simple Rectangle would be enough.
It is however convenient because we get the correct aspect ratio.
*/}
<Img
src={ImageSource}
width={360}
position={valuePosition}
rotation={valueLayerRotation}
/>
<Grid
compositeOperation={'source-in'}
stroke={'#ff0'}
width={1000}
rotation={45}
height={1000}
spacing={5}
lineWidth={1}
/>
</Node>,
);

// Legend (Bottom Center)
yield view.add(
<Rect
fill={'#1a1a1aa0'}
layout
direction={'row'}
gap={20}
padding={20}
bottom={() => view.getOriginDelta(Origin.Bottom)}
>
<Layout gap={5} alignItems={'center'}>
<Grid
stroke={'white'}
width={18}
height={18}
spacing={5}
lineWidth={1}
/>
<Txt fill={'white'}>Hidden Stencil / Mask Layer</Txt>
</Layout>
<Layout gap={5} alignItems={'center'}>
<Grid
stroke={'#ff0'}
rotation={45}
width={18}
height={18}
spacing={5}
lineWidth={1}
/>
<Txt fill={'white'}>Hidden Value Layer</Txt>
</Layout>
</Rect>,
);
yield* all(
fakeMaskLayerGroup().opacity(1, 1),
fakeValueLayerGroup().opacity(1, 1),
);
// Here comes the *actual* value and stencil mask. Because it got added last it will be ontop of the "fake" layers.
yield view.add(
<Node cache>
{/** Stencil / Mask Layer. It defines if the Value Layer is visible or not */}
<Img
src="/img/logo_dark.svg"
size={200}
position={maskPosition}
rotation={maskLayerRotation}
/>
{/** Value Layer. Anything from here will be visible if the Stencil Layer allows for it. */}
<Img
src={ImageSource}
width={360}
position={valuePosition}
rotation={valueLayerRotation}
compositeOperation={'source-in'}
/>
</Node>,
);

// Visible Loop
yield* all(
maskPosition(new Vector2(0, -30), 2),
valuePosition(new Vector2(0, -30), 2),
);
yield* maskLayerRotation(360, 2);
yield* valueLayerRotation(-360, 2);
yield* waitFor(1);
yield* all(
maskPosition(new Vector2(-150, -30), 2),
valuePosition(new Vector2(150, -30), 2),
);
yield* all(
fakeMaskLayerGroup().opacity(0, 1),
fakeValueLayerGroup().opacity(0, 1),
);

// Hidden Loop
yield* all(
maskPosition(new Vector2(0, -30), 2),
valuePosition(new Vector2(0, -30), 2),
);
yield* maskLayerRotation(2 * 360, 2);
yield* valueLayerRotation(2 * -360, 2);
yield* waitFor(1);
yield* all(
maskPosition(new Vector2(-150, -30), 2),
valuePosition(new Vector2(150, -30), 2),
);
});

Any of the following composite operations can be used to create a mask: source-in, source-out, destination-in, and destination-out. There is also a xor operation which can be helpful if you want two value layers that hide each other on overlap. Use the dropdown below to browse all examples.

Press play to preview the animation
import ...

// Image by Marek Piwnicki (https://unsplash.com/photos/_4o-1pr2oqU)
const ImageSource =
'https://images.unsplash.com/photo-1685901088371-f498db7f8c46';

export default makeScene2D(function* (view) {
view.fill('#141414');

const maskRef = createRef<Img>();
const valueRef = createRef<Img>();

yield view.add(
<Node cache>
{/** Stencil / Mask Layer. It defines if the Value Layer is visible or not */}
<Img ref={maskRef} size={250} src="/img/logo_dark.svg" />
{/** Value Layer. Anything from here will be visible if the Stencil Layer allows for it. */}
<Img
ref={valueRef}
x={100}
src={ImageSource}
width={600}
compositeOperation={'source-in'}
/>
</Node>,
);

yield maskRef().rotation(360, 4, linear);
yield* valueRef().x(-100, 1.5).wait(0.5).to(100, 1.5).wait(0.5);
});

Cached nodes

Both filters and composite operations require a cached Node. Filters can set it automatically, while composite operations require you to set it explicitly on an ancestor Node (usually the parent node).

A cached Node and its children are rendered on an offscreen canvas first, before getting added to the main scene.
For filters this is needed because they are applied to the entire canvas. By creating a new canvas and moving the elements that should get affected by the filters over, applying filters to the entire "new" canvas, and then moving back the result, you effectively only apply the filters to the moved elements.

To turn a Node into a cached node, simply pass the cache property

<Node cache>...</Node>
// or
<Node cache={true}>...</Node>

All components inherit from Node, so you can set the cache on all of them.