Shapes & layout
Position layers, group them, clip them, and arrange a scene.
Coordinates and anchors
The canvas origin is the top-left corner, measured in pixels. A layer’s x and y place its anchor, which is the top-left corner by default. Set anchorX and anchorY between 0 and 1 to move it: 0.5 is the center and 1 the right or bottom edge. Rotation (in degrees) and scale turn around the anchor too.
<Rect x={960} y={540} anchorX={0.5} anchorY={0.5}
width={400} height={400} cornerRadius={48}
rotation={12} scale={0.8} opacity={0.9} fill="#a68bbf" />
scaleX and scaleY override scale on one axis. opacity multiplies down through groups.
Shapes
Rect draws a rectangle with an optional cornerRadius, fill, and inner stroke with strokeWidth. Colors are hex strings, with an optional alpha channel: #RRGGBB or #RRGGBBAA. A square with a corner radius of half its size makes a circle.
Blur, shadows, and glow
Visual layers and groups accept blur, shadow, and glow, which can change every frame. Blur radii (up to 64 pixels) and shadow offsets use output pixels. Group effects apply to the combined children.
<Group glow={{ color: "#FFCC6680", blur: 12 }}>
<Text style={{ fontSize: 96 }}
shadow={{ color: "#000000B0", blur: 6, offsetX: 4, offsetY: 6 }}>
Celesta
</Text>
</Group>
Custom shaders
defineShader from @celesta/shader defines a filter in WGSL. Write fn effect(input: EffectInput) -> vec4f, which returns each output pixel as premultiplied RGBA. Read the layer with source_at(position) (scene pixels, interpolated) or source_load(pixel); input.uv is the pixel’s place in the layer’s box, from 0,0 to 1,1, and celesta holds the scene size and that box. Declare parameters with params and pass their values with every use; time is a parameter too, so a shader follows sequences and freeze frames. Pass the result to any layer’s shader prop. Pass an array, such as shader={[ripple({ time }), rgbSplit({ amount: 6 })]}, to apply several shaders in order, each filtering the result of the one before; every shader sees the same layer box, and their paddings add up. The shader runs before blur, shadow, and glow, and writes only the layer’s box plus its padding. Import a .wgsl file as the source to edit it with highlighting; saving it reloads the preview. Shaders run in preview and export, not in the browser editor.
import { defineShader } from '@celesta/shader';
const ripple = defineShader({
name: 'ripple',
wgsl: `fn effect(input: EffectInput) -> vec4f {
let center = mix(celesta.content.xy, celesta.content.zw, vec2f(0.5));
let offset = input.position - center;
let wave = sin(length(offset) * 0.06 - params.time * 6.0) * params.amplitude;
let direction = offset / max(length(offset), 1e-4);
return source_at(input.position - direction * wave);
}`,
params: { time: 'f32', amplitude: { type: 'f32', default: 6 } },
padding: 8,
});
<Text style={{ fontSize: 120 }} shader={ripple({ time: frame / fps })}>
Celesta
</Text>
Blend modes
Set blendMode on any layer to change how it combines with what is beneath it, as CSS mix-blend-mode does: normal (the default), multiply, screen, overlay, add, or difference. On a Group, any mode other than normal draws the children together first, then blends the result as one layer.
<Group blendMode="difference" opacity={0.8}>
<Text x={48} y={48} style={{ fill: { type: 'solid', color: '#ffffff' } }}>
REC 00:00:12
</Text>
</Group>
Group and arrange
Group has no size of its own. Its children are placed relative to its x/y, and its rotation, scale, and opacity apply to all of them. The layout helpers build on it:
Layout helpers
| API | Use it for |
|---|---|
Center |
Moves the origin to the center of the canvas or the enclosing SafeArea. |
SafeArea |
Insets its children by padding (a number, { top, right, bottom, left }, or the 'vertical-social' preset) and shrinks the area nested helpers see. |
Stack |
Places each child spacing pixels apart, vertical by default or horizontal. |
Grid |
Places children into columns of columnWidth × rowHeight cells, with optional gaps. |
Fit |
Scales content designed at sourceWidth × sourceHeight to contain or cover the current area. |
<SafeArea padding={96}>
<Center>
<Stack spacing={96} y={-96}>
{['Write', 'Preview', 'Export'].map((step) => (
<Text key={step} anchorX={0.5} anchorY={0.5}
style={{ fontSize: 64, fill: { type: 'solid', color: '#332f3b' } }}>
{step}
</Text>
))}
</Stack>
</Center>
</SafeArea>
Helpers position child origins; they do not measure what the children draw. Anchor children at 0.5 when you want them centered on those points.
Vertical video for Shorts, TikTok, and Reels
Use a 1080×1920 canvas. The players draw their own UI over the video: the caption, channel name, and progress bar cover about the bottom 20%, and the like, comment, and share buttons run down the right edge. Keep everything meant to be read (headlines, subtitles, logos, calls to action) inside <SafeArea>, and let backgrounds, footage, and decoration run to the edges. The 'vertical-social' preset keeps top 10%, right 18.5%, bottom 20%, and left 1/15 of the canvas clear (top 192, right 200, bottom 384, left 72 px at 1080×1920). It is a rough guide, not a platform specification, and does not follow changes to the platforms' UI; pass { top, right, bottom, left } when you know better.
<Composition width={1080} height={1920} fps={30} durationInFrames={300}>
<Image src="./bg.jpg" width={1080} height={1920} />
<SafeArea padding="vertical-social">
<Center>
<Text anchorX={0.5} anchorY={0.5} maxWidth={760}
style={{ fontSize: 96, align: 'center' }}>
Readable copy
</Text>
</Center>
</SafeArea>
<DebugOverlay safeArea="vertical-social" />
</Composition>
To check, give <DebugOverlay> the same safe area and export PNG frames or a contact sheet with --debug-guides (see Export). The area the UI covers is shaded; every line of text must stay inside the outline, including while it moves.
Clip a group
Give a Group a clip to draw its children only inside a rectangle, { x, y, width, height, cornerRadius }, in the group’s own coordinates. Use it for a mask reveal, a wipe, or content that scrolls inside a panel. The clip moves, scales, and rotates with the group, its edge is anti-aliased, and clips nested inside one another intersect.
<Group x={120} y={200} clip={{ width: 720, height: 96 }}>
<Text y={96 * (1 - reveal)}
style={{ fontSize: 88, fill: { type: 'solid', color: '#332f3b' } }}>
Layer it.
</Text>
</Group>