Core Concepts
Cereb is built around a few core abstractions that work together to provide a powerful yet lightweight gesture handling system.
Signal
A Signal is an immutable data container representing a single event at a point in time. Every pointer move, touch start, or gesture change produces a Signal.
interface Signal<K extends string, V> { readonly kind: K; // Type identifier (e.g., "single-pointer", "pan") readonly value: V; // The actual event data readonly deviceId: string; readonly createdAt: number;}Why Immutable?
Signals are designed to be readonly for two important reasons:
- No Side Effects - Immutable data prevents accidental mutations that could cause hard-to-debug issues in reactive pipelines.
- GC Optimization - The library internally reuses Signal objects through pooling. Treating them as readonly ensures predictable behavior.
Stream
A Stream is an observable sequence of Signals over time. It’s the pipeline through which all events flow.
interface Stream<T extends Signal> { subscribe(observer: (value: T) => void): Unsubscribe; block(): void; unblock(): void; readonly isBlocked: boolean;}Key Characteristics
- Lazy - No work happens until you call
subscribe() - Unicast by default - Each subscription creates its own event source. Use
share()for multicast. - Blockable - Temporarily pause event propagation without unsubscribing
import { singlePointer } from "cereb";
const stream = singlePointer(element);
// Events only start flowing when you subscribeconst unsubscribe = stream.subscribe((signal) => { console.log(signal.value.x, signal.value.y);});
// Temporarily pause events (useful during modal dialogs, etc.)stream.block();
// Resume event flowstream.unblock();
// Stop listening entirelyunsubscribe();Operators
Operators are functions that transform Streams. They take a Stream as input and return a new Stream with modified behavior.
type Operator<T extends Signal, R extends Signal> = (source: Stream<T>) => Stream<R>;Composing with pipe()
The pipe() function chains operators together, creating readable data transformation pipelines:
import { pipe, singlePointer } from "cereb";import { filter, throttle, offset } from "cereb/operators";
pipe( singlePointer(element), filter((s) => s.value.pointerType === "touch"), throttle(16), offset({ target: canvas }),).subscribe((signal) => { // Only touch events, throttled to ~60fps, with canvas-relative coordinates});Object Pooling
High-frequency events (60+ per second during gestures) can create garbage collection pressure. Cereb uses object pooling to reuse Signal objects, eliminating GC stutters during critical animations.
// The library handles pooling automatically.// This is what happens internally:
const signal = signalPool.acquire("single-pointer");// ... use signal ...signalPool.release(signal); // Returns to pool for reuse// Don't do thislet lastSignal: SinglePointerSignal;stream.subscribe((signal) => { lastSignal = signal; // This reference will become stale!});
// Do this insteadlet lastPosition = { x: 0, y: 0 };stream.subscribe((signal) => { lastPosition = { x: signal.value.x, y: signal.value.y }; // Extract the values});Session
A Session represents a bounded sequence of events with a clear start and end. For pointer input, a session typically spans from start (pointer down) to end (pointer up) or cancel.
import { pipe, singlePointer } from "cereb";import { singlePointerSession } from "cereb/operators";
pipe( singlePointer(element), singlePointerSession(), // Only emit during active pointer sessions).subscribe((signal) => { // signal.value.phase is "start" | "move" | "end" | "cancel"});The session() operator lets you define custom session boundaries:
session({ start: (signal) => signal.value.phase === "start", end: (signal) => signal.value.phase === "end" || signal.value.phase === "cancel",});Gesture Recognition
Cereb separates raw input handling from gesture recognition. The cereb package provides normalized pointer input, while gesture packages (like @cereb/pan) interpret that input as higher-level gestures.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ Pointer/Touch │────▶│ SinglePointer │────▶│ Pan Gesture ││ Events │ │ Signal │ │ Signal │└─────────────────┘ └─────────────────┘ └─────────────────┘ Raw DOM Normalized InterpretedSinglePointer
The singlePointer() function unifies mouse, touch, and pointer events into a consistent Signal format:
interface SinglePointer { phase: "start" | "move" | "end" | "cancel"; x: number; // clientX y: number; // clientY pageX: number; pageY: number; pointerType: "mouse" | "touch" | "pen" | "unknown"; button: "left" | "middle" | "right" | "none"; pressure: number; // 0.0 ~ 1.0 id: string;}Building Gestures
Gesture packages use operators to transform SinglePointer signals into gesture-specific signals:
import { pipe, singlePointer } from "cereb";import { panRecognizer, withVelocity } from "@cereb/pan";
pipe( singlePointer(element), panRecognizer({ threshold: 10 }), withVelocity(),).subscribe((pan) => { console.log(pan.value.deltaX, pan.value.velocityX);});Putting It Together
Here’s how all the concepts work together in a real-world example:
import { pipe, singlePointer } from "cereb";import { offset, singlePointerSession, throttle } from "cereb/operators";
const stream = pipe( // 1. Create a Stream from pointer events singlePointer(canvas),
// 2. Filter to active sessions only singlePointerSession(),
// 3. Add canvas-relative coordinates offset({ target: canvas }),
// 4. Limit to 60fps throttle(16),);
// 5. Subscribe to receive Signalsconst unsubscribe = stream.subscribe((signal) => { const { phase, offsetX, offsetY } = signal.value;
if (phase === "start") { beginPath(offsetX, offsetY); } else if (phase === "move") { drawTo(offsetX, offsetY); } else { endPath(); }});