Skip to main content

Animations

ShojiWM offers two ways to animate, with different trade-offs:

ApproachWhere it runsBest for
Signal-driven (window.animation + animationVariable)The TypeScript runtimeDecoration/chrome: hover, focus, GPU transforms, anything you map into arbitrary TSX
Compositor-driven (window.scheduleAnimation(...))The Rust coreManaged-window geometry/opacity: open, close, minimize, move, resize, workspace switches

The difference in one line: with the signal approach you compute each frame's value in TS and the composition re-runs; with scheduleAnimation you describe the whole animation once and Rust plays it back, applying the result as a delta on top of the layout — no per-frame TS work.

See Which should I use? at the bottom for guidance.


Signal-driven animations

These are driven by animation variables — named tokens whose value smoothly interpolates over time. You read a variable as a signal and feed it into transforms, opacity, or any other style; you start/stop it from event handlers. Each window keeps its own progress per variable.

Animation variables

Create a token once at module scope with animationVariable(debugName?), then use it through window.animation:

import {animationVariable, milliseconds, seconds} from 'shoji_wm';

const open = animationVariable('open');

COMPOSITOR.event.onOpen((window) => {
window.animation.start(open, {duration: seconds(0.18), from: 0, to: 1});
});

COMPOSITOR.event.onFocus((window, focused) => {
window.animation.start(open, {duration: milliseconds(120), to: focused ? 1 : 0});
});

milliseconds(n) and seconds(n) are readability helpers — both just return a millisecond number.

The animation controller

window.animation (an AnimationController) exposes:

MethodPurpose
variable(v)Read the variable's progress as a ReadonlySignal<number>
signal(v)Alias for variable
start(v, options)Start/restart an animation
stop(v)Stop, keeping the current value
set(v, value)Jump to a value, cancelling any running task
running(v)true while an animation is active

start options (AnimationStartOptions):

OptionTypeMeaning
durationnumber (ms)Total time
fromnumberStart value (defaults to the current value — smooth retargeting)
tonumberTarget value (defaults to 1)
easing(t: number) => numberEasing applied to 0..1 progress
repeat"loop" | "ping-pong"Repeat behavior

Omitting from makes direction changes and retargeting smooth — the animation continues from wherever the value currently is.

Reading a variable in composition

variable(v) returns a signal you can map into a style. Reading it inside composition makes the decoration update every frame the animation advances.

COMPOSITOR.window.composition = (window) => {
const t = window.animation.variable(open);
const scale = t((x) => 0.8 + x * 0.2); // 0.8 → 1.0
window.transform.scaleX = scale;
window.transform.scaleY = scale;
window.transform.opacity = t;
return (/* … */);
};

Because the value lives in a signal that composition reads, each frame triggers a (targeted) re-evaluation. That flexibility is the point — but it also means this path costs TS work per frame, so reserve it for chrome, not for the heavy geometry animations below.


Compositor-driven animations: scheduleAnimation

window.scheduleAnimation(options) hands a complete animation description to the Rust core, which interpolates it every frame on its own and applies the result to the managed window. The TS runtime is not involved per frame — there is no re-composition and no per-frame IPC — so this is the lightweight path for the frequent, heavy transitions (open/close/minimize/move/resize/workspace).

window.scheduleAnimation({
channel: 'open',
rect: {
from: {x: 0, y: 200, width: 0, height: 0},
to: {x: 0, y: 0, width: 0, height: 0},
duration: 500,
easing: {kind: 'cubicBezier', x1: 0.2, y1: 0, x2: 0, y2: 1},
mode: 'add',
},
opacity: {from: 0, to: 1, duration: 500, mode: 'multiply'},
});

What you can animate

ManagedWindowScheduleAnimationOptions has up to three independent properties, plus a channel:

FieldAnimatesOption type
rectThe window's {x, y, width, height}ManagedWindowRectAnimationOptions
offsetA positional {x, y} offsetManagedWindowPointAnimationOptions
opacityA scalar opacityManagedWindowScalarAnimationOptions
channel(string) groups the animation — see Channels

Each of rect / offset / opacity takes the same shape:

OptionTypeMeaning
tovalueTarget (required)
fromvalueStart (optional — defaults to the current value)
durationnumber (ms)Total time
easingeasingSee Easing (default linear)
mode"override" | "add" | "sub" | "multiply"How it combines with the base — see below

For rect and offset the value is {x, y, …}; for opacity it is a number. mode on rect/offset may not be "multiply".

Modes: how the animation combines with the layout

This is the key to scheduleAnimation. The animated value does not blindly replace the window's state — it is combined with the base value that your window manager is computing live, according to mode:

ModeResult
"override"animated — replace the base value
"add"base + animated — add a delta on top of the layout
"sub"base - animated
"multiply"base × animated (opacity only)

add is what makes these animations ride along with the layout. In the open example above, rect.mode: 'add' animates a +200px vertical offset that decays to 0 — so the window slides up into place relative to wherever the tiling/floating layout currently puts it. If the layout shifts mid-animation (another window opens, a tile resizes), the slide still lands correctly because Rust adds the animated delta to the live base rect each frame. multiply on opacity lets a fade compose with a window whose base opacity is already changing.

Easing

easing accepts:

  • "linear" (the default) or {kind: "linear"}
  • {kind: "cubicBezier", x1, y1, x2, y2} — a CSS-style cubic-bézier curve
  • An EasingFunction value
easing: {kind: 'cubicBezier', x1: 0.2, y1: 0, x2: 0, y2: 1}

Channels and cancellation

channel names an animation so independent animations can run at once and be targeted individually:

  • Scheduling again on the same channel replaces that channel's animation.
  • window.cancelAnimation(channel) cancels just that channel.
  • window.cancelAnimation() (no argument) cancels all channels.

The default config uses separate channels for open/close, minimize, and the workspace-switch visual, so e.g. switching workspaces while a window is still finishing its open animation doesn't interrupt the open.

const OPEN = 'open';
const WORKSPACE = 'workspace-visual';

window.scheduleAnimation({channel: OPEN, /* … */});
window.scheduleAnimation({channel: WORKSPACE, /* … */}); // runs alongside OPEN
window.cancelAnimation(WORKSPACE); // cancels only WORKSPACE

Real example: open & close

From the default window manager — note rect uses add (a decaying offset) and opacity uses multiply (a fade that composes with the base opacity):

function scheduleOpenAnimation(window) {
window.scheduleAnimation({
channel: 'open',
rect: {
from: {x: 0, y: 200, width: 0, height: 0},
to: {x: 0, y: 0, width: 0, height: 0},
duration: 500, easing: WINDOW_OPEN_EASING, mode: 'add',
},
opacity: {from: 0, to: 1, duration: 500, easing: WINDOW_OPEN_EASING, mode: 'multiply'},
});
}

function scheduleCloseAnimation(window) {
window.setCloseAnimationDuration(500); // keep the surface alive for the fade
window.scheduleAnimation({
channel: 'close',
rect: {
from: {x: 0, y: 0, width: 0, height: 0},
to: {x: 0, y: 120, width: 0, height: 0},
duration: 500, easing: WINDOW_CLOSE_EASING, mode: 'add',
},
opacity: {from: 1, to: 0, duration: 500, easing: WINDOW_CLOSE_EASING, mode: 'multiply'},
});
}

For close animations, pair scheduleAnimation with window.setCloseAnimationDuration(ms) so the compositor keeps the surface alive long enough to play the animation before destroying it.


Which should I use?

Use…When
scheduleAnimationAnimating the managed window's position/size/opacity — opens, closes, minimize, move, resize, workspace transitions. It's the lighter path (Rust interpolates, no per-frame TS), and add mode composes cleanly with live layout changes.
Signal-driven window.animationAnimating decoration you build in TSX — title-bar colors, hover/focus feedback, GPU transform/opacity derived from arbitrary logic. You get full flexibility at the cost of per-frame TS re-evaluation.

They can be combined: drive the window's entrance with scheduleAnimation while a focus glow on the border is driven by a signal variable.