Skip to content

Concepts

Riffle’s entire visual state comes from one number: position. Everything else, how far each card sits behind the front and how much it has shrunk, which card is on top, how far a card has slid off the edge, is a pure function of that one scalar plus a card’s index.

You rarely call these functions yourself: the engine underneath your stack calls them on every frame. They are exported from the core package, @rpxl/riffle, for when you want to reason about a layout or write your own.

For a card at index, its depth relative to the front is:

const depth = wrap(index - position, count)

wrap folds index - position into the range [-1, count - 1). Depth 0 is the front card. Depth -1 is off-screen, about to exit. Positive depth is further back in the fan. Because position is continuous, not an integer, depth moves smoothly as position changes, which is what makes a drag feel like it tracks the finger instead of snapping between states.

A layout strategy turns a depth into a Pose, a plain object of main, cross, rotation, scale, opacity, and zIndex. The default strategy, fan(), puts the front card at the origin and fans the rest out behind it at decreasing scale:

const layout = fan()
const pose = createPose()
layout.pose(depth, geometry, pose)

pose is written in place, so each card keeps one pose object for its whole life and the layout allocates nothing per frame.

Drag the slider. There is no engine running here at all: every time the input fires, this page recomputes wrap and fan().pose() for each card directly and writes the resulting transform, opacity, and z-index to the DOM itself, the same three pieces of work the engine does on every animation frame.

Watch the position readout above the slider: it is the same position value from the first code block, read to two decimal places.

Next: Gestures covers what a drag does to position, and Layouts walks through writing a layout strategy of your own.