Layouts
Everything a card stack looks like, how far back each card sits, how much it has shrunk, whether it
fades out, comes from one LayoutStrategy. The default, fan(), stacks cards straight behind one
another. This page walks through spread(), a second, real strategy shipped as
examples/custom-layout, that arcs cards out along a quarter turn instead, using only the public
@rpxl/riffle API a strategy is allowed to depend on.
What a LayoutStrategy is
Section titled “What a LayoutStrategy is”A LayoutStrategy has exactly two members:
pose(depth, geometry, out), called once per visible card on every animation frame the engine renders.depthis a continuous number (0 is the front card,-1is the off-screen exit slot a card passes through as it leaves), andposemust be pure and write its result field by field intooutrather than returning a new object: the engine allocates each card’sPoseonce and reuses it for that card’s whole life, so a strategy that returned a fresh object every frame would allocate steady garbage on the one code path (a drag in progress) that can least afford a GC pause.stepTravel(geometry), which returns the pixel distance of one full step ofdepth. The engine divides the pointer’s raw drag distance by this number to getprogress(see Gestures: the commit rule), so a strategy that returns too small a number makes every drag feel like it commits instantly, and too large a number makes the stack feel like it never lets go.
depth’s range depends on bounds: under the default 'loop', it wraps into [-1, count - 1);
under 'clamp', it is Math.max(index - position, -1) with no upper bound, so a strategy that
indexes an array by depth rather than computing from it needs its own guard.
See wrap() for the exact wrapping arithmetic.
A layout is framework agnostic: the same strategy object works with every way of building a stack.
spread(), worked
Section titled “spread(), worked”spread() borrows the reference layout’s exit slot for depth <= 0 (a drag past the front must
still track the pointer 1:1, in stepTravel()’s own units), and for depth > 0 arcs each card out
by angleStep degrees per unit of depth, at a radius close to one card’s extent, fading opacity
linearly over the last unit of depth before geometry.maxVisible. Its stepTravel() is
cardExtent + gap, the same as fan(). This is its pose, from the real, tested source:
pose(depth: number, geometry: LayoutGeometry, out: Pose): Pose { if (depth <= 0) { // The exit slot: borrowed from the reference fan() layout so a drag // past the front tracks the pointer 1:1 in the same units // stepTravel() reports above. const t = -depth // When depth is 0, -depth is -0. Multiplying by a positive number // preserves -0, and Object.is(-0, 0) is false, so the trailing + 0 // normalizes it rather than leaving a signed zero in the pose. out.main = t * (geometry.cardExtent + geometry.gap) + 0 out.cross = 0 out.rotation = 0 out.scale = 1 out.opacity = 1 - t * t } else { // The arc: each card behind the front sits `angleStep` degrees // further around a quarter turn, at a radius close to one card's // extent, so the stack reads as cards fanned open like a hand of // playing cards rather than stacked directly behind one another. const angleDeg = depth * angleStep const angleRad = (angleDeg * Math.PI) / 180 const radius = geometry.cardExtent * 0.9 out.main = radius * Math.sin(angleRad) out.cross = radius * (1 - Math.cos(angleRad)) out.rotation = angleDeg const scale = 1 - scaleStep * depth out.scale = scale < 0.5 ? 0.5 : scale // Linear fade over the last unit of depth before maxVisible, so a // card fades out exactly as the reference layout's cards do, just // computed without reaching for the core's internal smoothstep // helper (not part of the public API this example is limited to). const fade = geometry.maxVisible - depth out.opacity = fade <= 0 ? 0 : fade >= 1 ? 1 : fade } out.zIndex = Math.round(geometry.count - depth) return out},The demo is that same file, imported directly and driving a real createRiffle instance, the way
examples/custom-layout/src/main.ts does. Here is that example’s wiring: one layout instance,
passed to createRiffle, and one card element registered per index. The rest of the file measures
how far the arc reaches and sizes the stack’s box to match.
const layout = spread()
const riffle = createRiffle(stack, { count: cards.length, cardWidth: CARD_WIDTH, cardHeight: CARD_HEIGHT, maxVisible: MAX_VISIBLE, layout,})
cards.forEach((card, index) => { const el = document.createElement('div') el.className = 'card' el.style.width = `${CARD_WIDTH}px` el.style.height = `${CARD_HEIGHT}px` el.style.background = swatchGradient(card) el.textContent = card.label stack.appendChild(el) riffle.registerNode(index, el)})Writing your own
Section titled “Writing your own”A strategy only needs pose and stepTravel; everything else, measurement, dragging, springs, DOM
writes, stays the engine’s job. Start from the exit-slot branch above (depth <= 0) unchanged in
most strategies, since a custom look rarely has anything useful to say about a card mid-drag past
the front, only about the cards waiting behind it. Then:
- Decide what depth
1, 2, 3, ...should look like:spread()answers with an arc and a linear scale/opacity falloff, but amain/cross/rotation/scale/opacity/zIndexpose can encode any shape. - Pick
stepTravel()to match:spread()reusescardExtent + gap, the same distancefan()uses, so a drag tracks 1:1 right up to commit. A strategy whose cards travel further per step before settling should return a proportionally larger number.
- Pass it to
createRiffleaslayout: spread({ angleStep: 16 }), or your own strategy’s equivalent.
A pose with opacity 0 is special: the engine parks that card at the neutral pose (no translation,
rotation or scale), not at the offset the pose describes, so a card nobody can see never widens the
page’s scrollable area. Fade cards through the pose’s own opacity, as spread() does, rather
than a CSS opacity transition on the card: the transform moves to neutral in the same write that
sets opacity 0, so a transitioned fade would play out at the neutral position, not where the card
was.
See the full LayoutStrategy API reference for
every field on Pose and LayoutGeometry.
Next: Layout and overflow covers the room a stack needs around it, and where to clip it.