CAMDOM — Generator-Based Animation Engine
Deep-dive into the custom tweening motor: generator worklets, frame-accurate timing, composable primitives, real usage patterns, and potential as standalone library.
CAMDOM — Generator-Based Animation Engine
A generator-based animation motor built on React Native Reanimated worklets. 164 lines of code. Used across 5+ components. Potential candidate for extraction as a standalone library.
1. Core Insight
Traditional React Native animation libraries (Reanimated's withTiming, withSpring, Moti, React Native Reanimated Layout Animations) use configuration objects — you describe what should happen, and the library figures out when.
The CAMDOM engine inverts this: you write generators that describe sequential logic, and the engine drives them frame-by-frame on the UI thread. The result is code that reads like synchronous imperative logic but runs asynchronously inside Reanimated worklets.
// Traditional: declarative config
withTiming(value, { duration: 600, easing: Easing.inOut(Easing.ease) })
// CAMDOM: imperative generator
yield* timing(value, { to: 1, duration: 600 })
The difference is control flow. With config objects, you can't easily express "wait for this, then do that in parallel with this other thing, but only after this condition becomes true." With generators, that's just code.
2. Architecture
2.1 The Frame Loop
useFrameCallback (Reanimated UI thread)
│
▼
gen.value.next(timeSincePreviousFrame)
│
▼
Generator yields → engine captures delta time
│
▼
Next frame: generator resumes with accurate delta
The useAnimation hook registers a useFrameCallback on the Reanimated UI thread. Each frame, it calls gen.value.next(ts) where ts is the milliseconds since the last frame. The generator receives this delta via yield and uses it to advance its internal clock.
Key property: Animations are frame-accurate, not time-accurate. If a frame takes 33ms instead of 16ms, the next yield delivers the correct delta. There's no drift.
2.2 Generator Delegation
The engine uses JavaScript's yield* (generator delegation) as its composition mechanism. This is the critical design decision that makes the engine composable.
function* parallel(...inputs: Inputs) {
"worklet";
const iterators = inputs.map((input) => materializeGenerator(input));
let isDone = false;
let timeSinceFirstFrame = 0;
while (!isDone) {
const done = [];
for (const iterator of iterators) {
const val = iterator.next(timeSinceFirstFrame);
done.push(val.done);
}
isDone = done.every((d) => d);
if (!isDone) {
timeSinceFirstFrame = yield;
}
}
}
When you write yield* parallel(timingA, timingB), JavaScript delegates the outer generator's yield calls to the inner generators. The parallel primitive advances all iterators each frame, and yields back to the caller when it needs the next frame's delta. This is zero-cost composition — no arrays of callbacks, no promise chains, no event emitters.
2.3 Materialization
const materializeGenerator = (input: Input) => {
"worklet";
return typeof input === "function" ? input() : input;
};
Generators can be passed as factory functions (() => generator()) or live generators (generator()). Factory functions are used when a generator needs to be re-created (e.g., in parallel where each iterator needs its own state). This is a subtle but important ergonomic choice.
3. Primitive Reference
3.1 timeSincePreviousFrame()
export function* timeSincePreviousFrame() {
"worklet";
const time: number = yield;
return time;
}
The atomic primitive. Yields once, receives the frame delta, returns it. Every other primitive builds on this.
Why it exists: Directly accessing useFrameCallback's timeSincePreviousFrame parameter would couple every animation to the hook. By making it a generator, animations become pure functions that can be composed, nested, and tested independently of the rendering pipeline.
3.2 timing(value, config?)
export function* timing(value: SharedValue<number>, rawConfig?: TimingConfig) {
"worklet";
const from = value.value;
const { to, easing, duration } = { ...defaultTimingConfig, ...rawConfig };
const start: number = yield;
const end = start + duration;
for (let current = start; current < end; ) {
const progress = easing((current - start) / duration);
const val = interpolate(progress, [0, 1], [from, to]);
value.value = val;
current += yield* timeSincePreviousFrame();
}
value.value = to;
}
Behavior:
- Reads
fromfrom the current SharedValue (not a config parameter) - Yields once to capture the start time
- Loops frame-by-frame, computing easing progress and interpolating
- Snaps to final value on completion (no floating-point drift)
- Uses
yield* timeSincePreviousFrame()— frame-accurate
Default config:
{ to: 1, easing: Easing.inOut(Easing.ease), duration: 600 }
Design decision: from is read from value.value at generator start, not passed as a parameter. This means the animation always starts from the current state — you can't accidentally create a jump. If you need to set a specific start value, set value.value before yielding.
3.3 wait(duration)
export function* wait(duration = 1000) {
"worklet";
const from: number = yield;
const to = from + duration;
for (let current = from; current < to; ) {
current += yield* timeSincePreviousFrame();
}
}
A dead zone. Accumulates frame deltas until the target duration has elapsed. Used for delays in sequences and as the offset mechanism in stagger.
3.4 waitUntil(value, invert?)
export function* waitUntil(value: SharedValue<boolean>, invert = false) {
"worklet";
while (invert ? value.value : !value.value) {
yield;
}
}
The reactive primitive. Blocks the generator until a SharedValue becomes true (or false if invert = true). Each frame it checks the condition and yields if not met.
Usage in CAMDOM: Every component uses this to wait for menu open/close state:
yield* waitUntil(isOpened, !to); // wait for the toggle to flip
This is what enables the reactive animation pattern — animations don't fire on a trigger, they wait for state changes. The generator is alive the entire time the component is mounted, ready to respond.
3.5 parallel(...inputs)
export function* parallel(...inputs: Inputs) {
"worklet";
const iterators = inputs.map((input) => materializeGenerator(input));
let isDone = false;
let timeSinceFirstFrame = 0;
while (!isDone) {
const done = [];
for (const iterator of iterators) {
const val = iterator.next(timeSinceFirstFrame);
done.push(val.done);
}
isDone = done.every((d) => d);
if (!isDone) {
timeSinceFirstFrame = yield;
}
}
}
Runs N generators simultaneously. All receive the same frame delta. Completes when ALL generators are done.
The elegance: Because generators maintain their own local state (closures over start, current, from), each iterator in parallel is fully independent. They share only the frame delta. This is why you can nest parallel inside parallel without conflicts.
3.6 stagger(delay, ...inputs)
export function* stagger(delay: number, ...inputs: Inputs) {
"worklet";
const iterators = inputs.map((input, index) => {
return (function* () {
yield* wait(delay * index);
yield* materializeGenerator(input);
})();
});
yield* parallel(...iterators);
}
The composition primitive. For each input, creates a new generator that waits delay * index milliseconds, then runs the actual animation. All staggered generators are then run in parallel.
Usage in CAMDOM:
yield* stagger(duration / 2, timing(transition, { to, duration }));
When used with a single timing, this is functionally identical to timing() — the stagger adds 0ms delay for index 0. But when the lateral menu renders multiple children, each can have its own stagger with different delays, creating cascading entrance animations.
4. The Hook: useAnimation
export const useAnimation = <S extends AnimationState>(
input: Animation<S> | (() => Generator),
pause?: SharedValue<boolean>,
) => {
const offset = useSharedValue(0);
const { animation, state } =
typeof input === "function" ? { animation: input, state: {} as S } : input;
const values = useSharedValues(state);
const gen = useSharedValue<null | Generator>(null);
useFrameCallback(({ timeSincePreviousFrame: ts }) => {
if (gen.value === null) {
gen.value = animation(values);
}
if (pause?.value) {
offset.value += ts ?? 0;
} else {
if (gen.value.next) {
gen.value.next(ts);
} else {
gen.value = animation(values);
gen.value.next(ts);
}
}
});
return values;
};
Key behaviors:
-
Lazy initialization: Generator is created on first frame, not on mount. Avoids timing issues with SharedValue initialization.
-
Auto-restart: When the generator completes (
gen.value.nextis undefined), it's automatically re-created from the factory. This enables thewhile (true)loop pattern used in CAMDOM components — the animation is always alive, waiting for the next state change. -
Pause support: When
pauseSharedValue istrue, frame deltas are accumulated inoffsetinstead of being passed to the generator. This effectively freezes the animation without losing the generator's state. -
Automatic cleanup:
useSharedValuesregisters an effect that callscancelAnimationon all SharedValues when the component unmounts. -
Return value: Returns the
AnimationValuesobject — a record of SharedValues that the animation writes to and the component reads viauseAnimatedStyle.
5. Real Usage Patterns
5.1 The Menu Toggle Pattern (All Lateral Menu Components)
Every lateral menu component (BillyBoyVerticalLogo, BuyAComdomLateralText, LateralMenu, MenuIcon, CamdomLogo, UnlockYourPleasure) uses the same animation structure:
const animation = makeAnimation(
function* ({ isOpened, display, transition }) {
"worklet";
let to = 1;
while (true) {
// 1. Wait for state change
yield* waitUntil(isOpened, !to);
// 2. Show element (before animation starts)
if (isOpened.value) {
display.value = true;
}
// 3. Animate with stagger
yield* stagger(duration / 2, timing(transition, { to, duration }));
// 4. Hide element (after animation completes)
if (!isOpened.value) {
display.value = false;
}
// 5. Flip direction for next toggle
to = to === 1 ? 0 : 1;
}
},
{ isOpened: false, display: false, transition: 0 },
);
The pattern:
- Wait for a boolean SharedValue to flip
- Set display=true (show the element)
- Animate opacity 0→1 or 1→0
- Set display=false (hide the element after fade-out)
- Flip the target direction
- Loop back to step 1
Why while (true): The generator never exits. It's always alive, always waiting. When isOpened flips, the generator wakes up, runs the animation, and goes back to sleep. This is fundamentally different from event-driven animations — there's no "trigger" to miss.
5.2 The Component Integration
// 1. Create animation definition (module-level, shared across instances)
const animation = makeAnimation(generatorFn, initialState);
// 2. In the component, instantiate
const { isOpened, display, transition } = useAnimation(animation);
// 3. Drive state from React
useEffect(() => {
isOpened.value = isShowingLateralMenu;
}, [isShowingLateralMenu, isOpened]);
// 4. Read state for rendering
const style = useAnimatedStyle(() => ({
opacity: transition.value,
display: display.value ? "flex" : "none",
}));
The separation: Animation logic (generator) is defined at module level and shared. State (SharedValues) is instantiated per-component. React drives the input (isOpened), the generator handles the output (transition, display), and useAnimatedStyle bridges them to rendering.
6. Comparison with Other Approaches
| Approach | Composition | State | Frame Accuracy | Interruptibility |
|---|---|---|---|---|
Reanimated withTiming | Config objects | Implicit | Time-based | Limited (new animation overwrites) |
| Moti | Props-based | Prop-driven | Time-based | Good (key-based) |
| React Native Layout Animations | Entering/Exiting | Layout-triggered | Frame-based | Poor (can't interrupt) |
| CAMDOM generators | yield* delegation | Generator-local | Frame-accurate (delta) | Natural (generator state preserved) |
The advantage: Generator-based composition is orthogonal to the animation parameters. You can nest parallel(stagger(100, timing(a), timing(b)), wait(500), timing(c)) without any additional API surface. Each primitive does one thing; composition is free.
The disadvantage: Generators are unfamiliar to most React Native developers. The learning curve is steeper than prop-based APIs. Debugging is harder — you can't inspect generator state from React DevTools.
7. Potential as Standalone Library
7.1 What Would a Library Version Add?
The current implementation is 164 lines. A production library would need:
Core primitives (already exist):
timing,wait,waitUntil,parallel,stagger
Missing primitives:
sequence(...inputs)— run generators sequentially (trivial:for (const input of inputs) yield* input)spring(value, config?)— spring physics using Reanimated'swithSpringinternalsrepeat(count, generator)— repeat N times or infinitelycondition(predicate, then, else?)— branching inside generatorschain(...generators)— sequence with shared statecancel()— cooperative cancellation inside a generatorrace(...inputs)— complete when first generator finishes
Missing features:
- TypeScript generics — stronger typing for animation state
- Dev mode warnings — detect generators that never yield, detect stalled animations
- Performance monitoring — frame time tracking, jank detection
- Persistence — save/restore animation state for screen transitions
- Gesture integration —
fromGesture(gestureHandler)as a generator source - Scroll-driven —
fromScroll(scrollView)as a frame source
7.2 Package Architecture
react-native-generator-animations/
├── src/
│ ├── primitives/
│ │ ├── timing.ts
│ │ ├── spring.ts
│ │ ├── wait.ts
│ │ ├── waitUntil.ts
│ │ ├── parallel.ts
│ │ ├── stagger.ts
│ │ ├── sequence.ts
│ │ ├── repeat.ts
│ │ └── race.ts
│ ├── core/
│ │ ├── useAnimation.ts
│ │ ├── makeAnimation.ts
│ │ └── materializeGenerator.ts
│ ├── integration/
│ │ ├── fromGesture.ts
│ │ ├── fromScroll.ts
│ │ └── fromValue.ts
│ └── index.ts
├── examples/
│ ├── menu-toggle.tsx
│ ├── card-stack.tsx
│ └── scroll-parallax.tsx
└── package.json
7.3 Naming
Potential names:
- gen-motion — generator + motion
- yield-motion — the yield keyword as brand
- worklet-sequences — descriptive
- camdom-engine — origin story
7.4 What Makes It Different from Existing Libs
| Library | Paradigm | Composition | UI Thread |
|---|---|---|---|
| Reanimated | Config objects | Method chaining | Yes |
| Moti | React props | Prop spreading | Yes |
| Animated API | Value listeners | Nested callbacks | No (bridge) |
| Generator Engine | Generator functions | yield* delegation | Yes (worklet) |
The unique value proposition: imperative control flow on the UI thread. No other React Native animation library gives you for loops, if/else, and while inside an animation sequence. Generators do.
7.5 Open Source Viability
Strengths:
- 164 lines — trivially auditable
- Zero dependencies beyond Reanimated
- Frame-accurate — technically superior to time-based approaches
- Composable —
yield*is a language feature, not a library API - Proven in production (CAMDOM, 300K+ downloads, 30+ countries)
Weaknesses:
- Small surface area — competitors have more primitives out of the box
- Generator unfamiliarity — most JS developers don't use generators daily
- No spring physics yet — would need Reanimated's spring internals
- Debugging opacity — generator state is invisible to dev tools
Opportunity: The React Native animation ecosystem is fragmented. Reanimated is powerful but verbose. Moti is ergonomic but limited. A generator-based approach occupies a unique niche: composable imperative animations on the UI thread. If documented well with examples, it could find a audience among developers who've felt the limits of config-based animation.
8. The Philosophical Design
The deepest insight in this engine is that animations are sequential logic, not declarative state.
When you write yield* waitUntil(isOpened), you're expressing intent: "wait here until the user does something." When you write yield* stagger(100, timing(a), timing(b)), you're expressing choreography: "these things happen in sequence, with this offset."
Traditional animation libraries force you to express choreography through configuration:
// Reanimated: nested config hell
withSequence(
withDelay(100, withTiming(val1, { duration: 300 })),
withDelay(200, withTiming(val2, { duration: 300 }))
)
Generator-based choreography:
// CAMDOM: sequential logic
yield* wait(100);
yield* parallel(timing(val1, { duration: 300 }), timing(val2, { duration: 300 }));
The second version is not just shorter — it's legible. You can read the timeline left-to-right, top-to-bottom. The control flow is explicit. And because generators can contain if/else, for, and while, you can express conditional choreography without additional API surface:
if (shouldAnimateIn) {
yield* timing(value, { to: 1, duration: 300 });
} else {
value.value = 1; // instant snap
}
yield* wait(200);
yield* parallel(timing(a), timing(b));
No config-based library can express that without adding a condition() primitive. With generators, it's just JavaScript.
Source: condom-app/utils/Animations.ts (164 lines) + component usage analysis See also: camdom-architecture, camdom-design-system