Skip to content

Clamp with controls

bounds: 'loop' is the default: drag past the last card and the first one comes back around. Set bounds: 'clamp' instead for a stack that stops dead at both ends, the right choice for a fixed, ordered sequence like a trail of waypoints or a numbered set of steps, where “after the last one” should mean nothing rather than wrapping back to the start.

Card 1 of 5

Invented trail waypoints, each with a two-stop gradient standing in for a photo.

waypoints.ts
/**
* Invented trail waypoints for the clamp demo. Each gets a two-stop CSS
* gradient instead of a photo, since there is no licensed imagery to show.
*/
export interface Waypoint {
name: string
elevation: string
from: string
to: string
}
export const waypoints: readonly Waypoint[] = [
{ name: 'Cedar Gate', elevation: '410 m', from: '#1a2e1c', to: '#5fae5c' },
{ name: 'Long Ridge', elevation: '890 m', from: '#20242f', to: '#7b8fb8' },
{ name: 'Quarry Bend', elevation: '650 m', from: '#332415', to: '#c98a4a' },
{ name: 'Foxglove Meadow', elevation: '520 m', from: '#2a1730', to: '#b569c9' },
{ name: 'Windrow Pass', elevation: '1120 m', from: '#122530', to: '#59b3c9' },
]
/** A waypoint's background: a single diagonal two-stop gradient. */
export function waypointGradient(waypoint: Waypoint): string {
return `linear-gradient(155deg, ${waypoint.from}, ${waypoint.to})`
}

The snapshot carries canPrev and canNext, false exactly when bounds: 'clamp' has nothing further in that direction. Bind those straight to the native disabled attribute, not to a CSS class that only looks disabled: a real disabled button drops out of the tab order’s activation entirely, so a keyboard or screen reader user hits a control that does nothing to click, never a click that silently fails.

ClampStack.tsx
const handle = useRiffle({
count: waypoints.length,
bounds: 'clamp',
cardWidth: CARD_WIDTH,
cardHeight: CARD_HEIGHT,
getLabel,
})
const activeIndex = useRiffleState(handle, (s) => s.activeIndex)
const canPrev = useRiffleState(handle, (s) => s.canPrev)
const canNext = useRiffleState(handle, (s) => s.canNext)
<button
type="button"
style={canPrev ? controlButtonStyle : controlButtonDisabledStyle}
aria-label="Previous waypoint"
disabled={!canPrev}
onClick={handle.prev}
>
<span aria-hidden="true">&#8249;</span>
</button>
<span
data-testid="readout"
style={{ minWidth: '8rem', textAlign: 'center', color: '#9c988e' }}
>
Card {activeIndex + 1} of {waypoints.length}
</span>
<button
type="button"
style={canNext ? controlButtonStyle : controlButtonDisabledStyle}
aria-label="Next waypoint"
data-testid="next-button"
disabled={!canNext}
onClick={handle.next}
>
<span aria-hidden="true">&#8250;</span>
</button>

Next: Programmatic control covers driving the stack from further outside than a pair of adjacent buttons.