Skip to content

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.

A LayoutStrategy has exactly two members:

  • pose(depth, geometry, out), called once per visible card on every animation frame the engine renders. depth is a continuous number (0 is the front card, -1 is the off-screen exit slot a card passes through as it leaves), and pose must be pure and write its result field by field into out rather than returning a new object: the engine allocates each card’s Pose once 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 of depth. The engine divides the pointer’s raw drag distance by this number to get progress (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() 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:

spread-layout.ts
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.

main.ts
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)
})

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:

  1. Decide what depth 1, 2, 3, ... should look like: spread() answers with an arc and a linear scale/opacity falloff, but a main/cross/rotation/scale/opacity/zIndex pose can encode any shape.
  2. Pick stepTravel() to match: spread() reuses cardExtent + gap, the same distance fan() 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.
  1. Pass it to createRiffle as layout: 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.