Six things you could build with Riffle

Every stack on this page is live. The products are made up.

Orrery Air

Boards09:40

LDNLondon

LSBLisbon

Passenger
Ines Moreau
Cabin
Economy
Flight
OR 214
Date
14 Jun
Departs
10:15
Gate
B12
Seat
14A
Group
2

Gate closes at 10:00

Orrery Air

Boards13:05

LSBLisbon

MRKMarrakesh

Passenger
Ines Moreau
Cabin
Economy
Flight
OR 351
Date
18 Jun
Departs
13:40
Gate
A04
Seat
07F
Group
1

Gate closes at 13:25

Orrery Air

Boards06:50

MRKMarrakesh

RKVReykjavik

Passenger
Ines Moreau
Cabin
Economy
Flight
OR 1180
Date
22 Jun
Departs
07:30
Gate
C21
Seat
22C
Group
3

Gate closes at 07:10

Orrery Air

Boards23:10

RKVReykjavik

KIXOsaka

Passenger
Ines Moreau
Cabin
Premium
Flight
OR 88
Date
27 Jun
Departs
23:55
Gate
D02
Seat
31K
Group
4

Gate closes at 23:35

Orrery Air

Boards11:25

KIXOsaka

SELSeoul

Passenger
Ines Moreau
Cabin
Business
Flight
OR 730
Date
02 Jul
Departs
12:00
Gate
E15
Seat
03A
Group
1

Gate closes at 11:45

Drag the front pass down, or use the arrow keys.

01

Boarding passes

Five legs of one trip, stacked top to bottom. The stack stops at the last leg.

axis: 'y'bounds: 'clamp'fan({ offset: 60 })on('change')

Show codeHide code
import { createRiffle, fan } from '@rpxl/riffle'

const passes = Array.from(stack.querySelectorAll<HTMLElement>('.pass'))

const riffle = createRiffle(stack, {
  count: passes.length,
  axis: 'y',
  bounds: 'clamp',
  cardWidth: 'auto',
  cardHeight: 'auto',
  maxVisible: 4,
  layout: fan({ offset: 60, scaleStep: 0.07 }),
  getLabel: (i) => passes[i]!.dataset.label!,
})
passes.forEach((pass, i) => riffle.registerNode(i, pass))

// The whole scene takes on the colour of wherever you fly next.
riffle.on('change', ({ index }) => {
  scene.style.setProperty('--bg', passes[index]!.dataset.tint!)
})

// Clamped at both ends, so the buttons say when there is nowhere to go.
const sync = () => {
  const { activeIndex, canPrev, canNext } = riffle.getSnapshot()
  prevButton.setAttribute('aria-disabled', String(!canPrev))
  nextButton.setAttribute('aria-disabled', String(!canNext))
  counter.textContent = `${activeIndex + 1} / ${passes.length}`
}
riffle.subscribe(sync)
sync()
prevButton.addEventListener('click', () => riffle.prev())
nextButton.addEventListener('click', () => riffle.next())

Concrete Bloom

Mira Solano

Night Bus Hymns

The Low Lanterns

Tangerine Static

Odalys and the Wires

Parallax

Kofi Mensah-Dahl

Soft Machines

Ines Varga Quartet

Moss Hours

Heron Coast

Now playing: Concrete Bloom by Mira Solano

Drag sideways. It loops.

02

Record crate

Sleeves loop like a shop bin. When the stack settles, the record slides out.

bounds: 'loop'on('settle')on('dragstart')

Show codeHide code
import { createRiffle, fan } from '@rpxl/riffle'

const sleeves = Array.from(stack.querySelectorAll<HTMLElement>('.sleeve'))

// bounds defaults to 'loop': the crate never runs out.
const riffle = createRiffle(stack, {
  count: sleeves.length,
  cardWidth: 'auto',
  cardHeight: 'auto',
  maxVisible: 4,
  layout: fan({ offset: 30, scaleStep: 0.06 }),
  getLabel: (i) => sleeves[i]!.dataset.label!,
})
sleeves.forEach((sleeve, i) => riffle.registerNode(i, sleeve))

// At rest, the record slides out of the front sleeve and spins.
riffle.on('settle', ({ index }) => sleeves[index]!.classList.add('is-playing'))

// Grab the stack, or move to another sleeve, and it slides back in.
const tuckAway = () => sleeves.forEach((sleeve) => sleeve.classList.remove('is-playing'))
riffle.on('dragstart', tuckAway)
riffle.on('change', ({ index }) => {
  tuckAway()
  nowPlaying.textContent = sleeves[index]!.dataset.label!
})

// The first sleeve never settled into place, so it starts out playing.
sleeves[0]!.classList.add('is-playing')

Stage 1

Emberfox

HP120

Ember type Shardling, stage 1

  • Cinder Flick30
  • Tail Bonfire90
Weakness
Tide x2
Resistance
None
Retreat
1

Leaves warm footprints in fresh snow. Nobody has ever caught one asleep.

Shardlings014/120

Stage 2

Tidewyrm

HP140

Tide type Shardling, stage 2

  • Undertow50
  • King Swell130
Weakness
Spark x2
Resistance
None
Retreat
2

Sailors blame it for every wave that arrives a second too early.

Shardlings037/120

Basic

Voltmoth

HP80

Spark type Shardling, basic

  • Static Dust20
  • Porch Light60
Weakness
Grove x2
Resistance
None
Retreat
1

Drawn to streetlights, which tend to flicker for a week afterwards.

Shardlings052/120

Stage 2

Thornback

HP160

Grove type Shardling, stage 2

  • Bramble Wall40
  • Old Growth120
Weakness
Ember x2
Resistance
None
Retreat
2

Moves one field a year. Moss grows on the shell faster than it walks.

Shardlings088/120

Stage 1

Gloomowl

HP100

Dusk type Shardling, stage 1

  • Hush20
  • Midnight Stare100
Weakness
Spark x2
Resistance
None
Retreat
1

Asks one question each night. Nobody remembers the answer by morning.

Shardlings101/120

Grab a corner and drag.

03

Trading cards

Grab a corner and the card tilts on it. The foil follows the drag.

rotation: { maxRotation: 24 }on('drag')progressoffset

Show codeHide code
import { createRiffle } from '@rpxl/riffle'

const cards = Array.from(stack.querySelectorAll<HTMLElement>('.holo'))

const riffle = createRiffle(stack, {
  count: cards.length,
  cardWidth: 'auto',
  cardHeight: 'auto',
  // A big tilt, mostly from where you grab: corners swing hardest.
  rotation: { maxRotation: 24, leverFactor: 0.9 },
  getLabel: (i) => cards[i]!.dataset.label!,
})
cards.forEach((card, i) => riffle.registerNode(i, card))

let held: HTMLElement | null = null
let width = 1
riffle.on('dragstart', ({ index }) => {
  held = cards[index]!
  width = held.offsetWidth
  held.classList.add('is-held')
})

// Every frame of the drag: no allocation, just two numbers for the CSS.
riffle.on('drag', ({ progress, offset }) => {
  if (!held) return
  held.style.setProperty('--shift', (offset / width).toFixed(3))
  held.style.setProperty('--glow', Math.min(1, Math.abs(progress)).toFixed(3))
})

// Let go and the light settles back with a CSS transition.
riffle.on('dragend', () => {
  if (!held) return
  held.classList.remove('is-held')
  held.style.setProperty('--shift', '0')
  held.style.setProperty('--glow', '0')
  held = null
})

No. 01Noun

花火

はなびhanabi

No. 02Noun

駅

えきeki

No. 03Noun

猫

ねこneko

No. 04Noun

雨

あめame

No. 05Noun

朝ご飯

あさごはんasagohan

No. 06Noun

友達

ともだちtomodachi

Press Enter or Space to flip this card.

←→ move, Enter or Space flips

04

Flashcards

Arrow keys move, Enter flips. Nothing here needs a mouse.

bounds: 'clamp'on('change')getLabel

Show codeHide code
import { createRiffle } from '@rpxl/riffle'

const cards = Array.from(stack.querySelectorAll<HTMLElement>('.flashcard'))

const riffle = createRiffle(stack, {
  count: cards.length,
  bounds: 'clamp',
  cardWidth: 'auto',
  cardHeight: 'auto',
  maxVisible: 3,
  getLabel: (i) => cards[i]!.dataset.label!,
})
cards.forEach((card, i) => riffle.registerNode(i, card))

// Only the face you can see is in the accessibility tree.
const setFlipped = (card: HTMLElement, flipped: boolean) => {
  card.classList.toggle('is-flipped', flipped)
  card.querySelector('.front')!.setAttribute('aria-hidden', String(flipped))
  card.querySelector('.back')!.setAttribute('aria-hidden', String(!flipped))
  live.textContent = flipped ? card.dataset.answer! : ''
}

// A tap flips, but a drag that started on the card does not.
let dragged = false
stack.addEventListener('pointerdown', () => (dragged = false))
riffle.on('dragstart', () => (dragged = true))
for (const card of cards) {
  const flip = () => setFlipped(card, !card.classList.contains('is-flipped'))
  card.addEventListener('click', () => dragged || flip())
  card.addEventListener('keydown', (event) => {
    if (event.target !== card || (event.key !== 'Enter' && event.key !== ' ')) return
    event.preventDefault()
    flip()
  })
}

// Arrow keys, Home and End are built in. Moving on turns the last card back.
riffle.on('change', ({ index, previousIndex }) => {
  setFlipped(cards[previousIndex]!, false)
  dots.forEach((dot, i) => dot.classList.toggle('is-active', i === index))
})

Fernwick

Ethiopia

Yirgacheffe, washed

  • Jasmine
  • Bergamot
  • White peach
Altitude
2,000 m
Variety
Heirloom
Light roast

250 g, whole bean

Fernwick

Colombia

Huila, washed

  • Red apple
  • Panela
  • Cocoa
Altitude
1,700 m
Variety
Caturra
Medium roast

250 g, whole bean

Fernwick

Kenya

Nyeri, washed

  • Blackcurrant
  • Grapefruit
  • Cane sugar
Altitude
1,800 m
Variety
SL28
Light medium roast

250 g, whole bean

Fernwick

Guatemala

Huehuetenango, washed

  • Milk chocolate
  • Almond
  • Orange
Altitude
1,750 m
Variety
Bourbon
Medium roast

250 g, whole bean

Fernwick

Sumatra

Gayo, wet hulled

  • Cedar
  • Dark chocolate
  • Clove
Altitude
1,400 m
Variety
Typica
Dark roast

250 g, whole bean

Fernwick

Brazil

Mogiana, natural

  • Hazelnut
  • Caramel
  • Raisin
Altitude
1,100 m
Variety
Mundo Novo
Medium dark roast

250 g, whole bean

Drag a bag or tap the arrows.

05

Coffee origins

The page takes the colour of whichever bag is in front.

on('change')style.setProperty('--bg')style.setProperty('--ink')

Show codeHide code
import { createRiffle, fan } from '@rpxl/riffle'

const bags = Array.from(stack.querySelectorAll<HTMLElement>('.bag'))

const riffle = createRiffle(stack, {
  count: bags.length,
  cardWidth: 'auto',
  cardHeight: 'auto',
  maxVisible: 4,
  layout: fan({ offset: 36, scaleStep: 0.06 }),
  getLabel: (i) => bags[i]!.dataset.label!,
})
bags.forEach((bag, i) => riffle.registerNode(i, bag))

// Your brand, your colours: the scene floods to the bag in front, and
// the headline, copy and buttons follow through the --ink property.
riffle.on('change', ({ index }) => {
  const { flood, ink } = bags[index]!.dataset
  scene.style.setProperty('--bg', flood!)
  scene.style.setProperty('--ink', ink!)
})

prevButton.addEventListener('click', () => riffle.prev())
nextButton.addEventListener('click', () => riffle.next())

Two Days InMexico City, Sat 08:30

Chilaquiles verdes and a coffee strong enough to argue with.

Two Days InMexico City, Sat 11:00

Roma Norte under the jacarandas. Look up. The purple is the point.

Two Days InMexico City, Sat 14:00

A market counter, a plastic stool, and whatever the cook says is good today.

Two Days InMexico City, Sat 19:30

Golden hour over the old town. Bring a jacket, it cools fast.

Two Days InMexico City, Sun 10:00

Chapultepec by pedal boat. Go slowly and call it exercise.

Two Days InMexico City, Sun 13:00

Al pastor off the spit. Order two more than you planned.

Hover, focus or grab a story to hold it.

06

City stories

Advances on its own every five seconds and holds while you touch it.

next()on('dragstart')prefers-reduced-motion

Show codeHide code
import { createRiffle } from '@rpxl/riffle'

const stories = Array.from(stack.querySelectorAll<HTMLElement>('.story'))

const riffle = createRiffle(stack, {
  count: stories.length,
  cardWidth: 'auto',
  cardHeight: 'auto',
  maxVisible: 3,
  getLabel: (i) => stories[i]!.dataset.label!,
})
stories.forEach((story, i) => riffle.registerNode(i, story))
prevButton.addEventListener('click', () => riffle.prev())
nextButton.addEventListener('click', () => riffle.next())
riffle.on('change', ({ index }) => {
  segments.forEach((segment, i) => {
    segment.classList.toggle('is-done', i < index)
    segment.classList.toggle('is-active', i === index)
  })
})
if (prefersReducedMotion()) return // No auto-advance, ever.
bar.addEventListener('animationend', () => riffle.next())
// Any one reason to wait holds the bar, and with it the timer.
const holds = new Set<string>()
const hold = (reason: string, on: boolean) => {
  holds[on ? 'add' : 'delete'](reason)
  bar.classList.toggle('is-paused', holds.size > 0)
}
riffle.on('dragstart', () => hold('drag', true))
riffle.on('dragend', () => hold('drag', false))
stack.addEventListener('pointerenter', (e) => hold('hover', e.pointerType === 'mouse'))
stack.addEventListener('pointerleave', () => hold('hover', false))
stack.addEventListener('focusin', () => hold('focus', true))
stack.addEventListener('focusout', () => hold('focus', false))
pauseButton.addEventListener('click', () => {
  const paused = pauseButton.getAttribute('aria-pressed') !== 'true'
  pauseButton.setAttribute('aria-pressed', String(paused))
  hold('button', paused)
})