# Stories

Round avatars morph into 9:16 cards. Each person has several frames: tap the right side (or →) for the next, the left side (or ←) for the previous, hold to pause. Past the last frame the cube turns to the next person: the cards are the faces of a 3D box, turned by the native scroll position with a pure-CSS scroll-driven animation, while the arrows stay put. Pull down to dismiss.

```tsx
import { Lightbox, useLightbox } from '@frontendsupply/lightbox'
import * as React from 'react'
import { portraits } from '../../data/photos'

const frameUrl = (id: number, w = 720, h = 1280) => `https://picsum.photos/id/${id}/${w}/${h}.webp`
const idOf = (i: number) => Number(portraits[i]!.src.split('/')[4])

const people = [
  { name: 'Maya', time: '2h', frames: [idOf(0), 60, 73] },
  { name: 'Julian', time: '3h', frames: [idOf(1), 87] },
  { name: 'Noor', time: '5h', frames: [idOf(2), 101, 115, 128] },
  { name: 'Eli', time: '6h', frames: [idOf(3)] },
  { name: 'Sana', time: '8h', frames: [idOf(4), 142, 157] },
  { name: 'Priya', time: '9h', frames: [idOf(5), 170] },
  { name: 'Tomás', time: '12h', frames: [idOf(6), 183, 196] },
  { name: 'Ines', time: '20h', frames: [idOf(7), 211] },
]

// One lightbox item per person; each slide shows that person's current frame.
const items = people.map((person) => ({
  src: frameUrl(person.frames[0]!),
  thumb: frameUrl(person.frames[0]!, 180, 320),
  width: 720,
  height: 1280,
  alt: `${person.name}'s story`,
}))

const chevron = (d: string) => (
  <svg
    viewBox="0 0 24 24"
    className="size-5"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden
  >
    <path d={d} />
  </svg>
)

function StoryChrome({
  person,
  slide,
  frame,
  onDone,
}: {
  person: (typeof people)[number]
  slide: number
  frame: number
  onDone: () => void
}) {
  const { index, phase } = useLightbox()
  const playing = index === slide && phase === 'open'

  // Warm the cache so the next frame swaps in instantly.
  React.useEffect(() => {
    const next = person.frames[frame + 1]
    if (playing && next) new window.Image().src = frameUrl(next)
  }, [playing, person, frame])

  return (
    <div className="pointer-events-none absolute inset-0 grid place-items-center sm:py-8">
      {/* Same box as the fitted 9:16 image, via container query units on the slide. */}
      <div className="relative aspect-9/16 w-[min(100cqw,calc(100cqh*9/16))] opacity-(--lightbox-presence)">
        <div className="absolute inset-x-0 top-0 bg-linear-to-b from-black/55 to-transparent p-3 pb-12 transition-opacity group-data-dragging:opacity-0 sm:rounded-t-2xl">
          <div className="flex gap-1">
            {person.frames.map((_, f) => (
              // biome-ignore lint/suspicious/noArrayIndexKey: one bar per frame slot
              <div key={f} className="h-0.5 flex-1 overflow-hidden rounded-full bg-white/35">
                {f < frame && <div className="h-full bg-white" />}
                {f === frame && playing && (
                  <div
                    key={frame}
                    className="h-full origin-left animate-[story-progress_5s_linear_forwards] bg-white group-active/slide:[animation-play-state:paused] group-data-dragging:[animation-play-state:paused]"
                    onAnimationEnd={onDone}
                  />
                )}
              </div>
            ))}
          </div>
          <div className="mt-3 flex items-center gap-2 text-sm">
            <img src={frameUrl(person.frames[0]!, 64, 64)} alt="" className="size-8 rounded-full object-cover" />
            <span className="font-semibold">{person.name}</span>
            <span className="text-white/60">{person.time}</span>
            <Lightbox.Close
              className="pointer-events-auto ml-auto grid size-8 place-items-center rounded-full text-white/90 hover:bg-white/10"
              aria-label="Close"
            >
              {chevron('M18 6 6 18M6 6l12 12')}
            </Lightbox.Close>
          </div>
        </div>
      </div>
    </div>
  )
}

/** Arrows beside the card. They live in the overlay, not on a face, so they stay put while the cube turns. */
function StoryArrows({ onStep }: { onStep: (dir: 1 | -1) => void }) {
  const side =
    'pointer-events-auto absolute top-1/2 hidden size-10 -translate-y-1/2 place-items-center rounded-full bg-white/15 backdrop-blur transition hover:bg-white/25 sm:grid'
  return (
    <div className="pointer-events-none absolute inset-0 opacity-(--lightbox-presence)">
      <div className="absolute inset-0 transition-opacity group-data-dragging:opacity-0">
        <button
          type="button"
          aria-label="Previous"
          className={`${side} left-[calc(50%-var(--card-w)/2-3.5rem)]`}
          onClick={() => onStep(-1)}
        >
          {chevron('m15 18-6-6 6-6')}
        </button>
        <button
          type="button"
          aria-label="Next"
          className={`${side} right-[calc(50%-var(--card-w)/2-3.5rem)]`}
          onClick={() => onStep(1)}
        >
          {chevron('m9 18 6-6-6-6')}
        </button>
      </div>
    </div>
  )
}

function Viewer({ frames, setFrame }: { frames: number[]; setFrame: (person: number, frame: number) => void }) {
  const { index, goTo, close } = useLightbox()

  // Frames first, then people: past the last frame → next person (cube turn), past the first → previous person.
  const step = (person: number, dir: 1 | -1) => {
    const next = frames[person]! + dir
    if (next < 0) return person > 0 && goTo(person - 1)
    if (next >= people[person]!.frames.length) return person < people.length - 1 ? goTo(person + 1) : close()
    setFrame(person, next)
  }

  return (
    <Lightbox.Portal>
      <Lightbox.Backdrop className="z-50 bg-neutral-950" />
      <Lightbox.Popup
        // The card: the largest 9:16 that fits (leaving room for the arrows on wider screens).
        className="group z-50 text-white outline-none [--card-w:min(100vw,calc(100dvh*9/16))] sm:[--card-w:min(100vw-8rem,calc((100dvh-4rem)*9/16))]"
        onKeyDown={(e) => {
          if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
          e.preventDefault() // replaces the default slide navigation
          step(index, e.key === 'ArrowRight' ? 1 : -1)
        }}
      >
        {/* Slides as wide as the card, so neighbouring cards share an edge and one swipe is one face. */}
        <Lightbox.Viewport className="px-[calc(50%-var(--card-w)/2)]">
          {people.map((person, i) => (
            <Lightbox.Slide
              key={person.name}
              index={i}
              style={{ flex: '0 0 var(--card-w)', overflow: 'visible' }}
              className="group/slide @container-size sm:py-8"
              onTap={(e) => {
                if (e.target !== 'image') return
                e.preventDefault()
                step(i, e.x < 0.3 ? -1 : 1)
              }}
            >
              {/* The cube turn rotates this face, never the slide: snap points come from the slide's box. */}
              <div className="story-face absolute inset-0">
                <Lightbox.Image src={frameUrl(person.frames[frames[i]!]!)} className="sm:rounded-2xl" />
                <StoryChrome person={person} slide={i} frame={frames[i]!} onDone={() => step(i, 1)} />
              </div>
            </Lightbox.Slide>
          ))}
        </Lightbox.Viewport>
        <StoryArrows onStep={(dir) => step(index, dir)} />
      </Lightbox.Popup>
    </Lightbox.Portal>
  )
}

export default function Stories() {
  const [frames, setFrames] = React.useState(() => people.map(() => 0))
  const setFrame = (person: number, frame: number) => setFrames((fs) => fs.map((f, i) => (i === person ? frame : f)))

  return (
    <Lightbox.Root items={items} maxZoom={1} onOpenChange={(open) => open && setFrames(people.map(() => 0))}>
      {/* Bleeds to the demo frame's edges with the padding inside, so avatars scroll right up to the border. */}
      <div className="-mx-4 flex gap-4 overflow-x-auto px-4 py-2 scrollbar-none sm:-mx-5 sm:px-5">
        {items.map((item, i) => (
          <div key={item.src} className="flex shrink-0 flex-col items-center gap-1.5">
            <span className="rounded-full bg-linear-to-tr from-amber-400 via-rose-500 to-fuchsia-600 p-[2.5px]">
              <Lightbox.Trigger
                index={i}
                className="block size-16 overflow-hidden rounded-full border-[3px] border-white bg-neutral-200 outline-none dark:border-neutral-950 dark:bg-neutral-800"
              >
                <img src={item.thumb} alt={item.alt} className="size-full object-cover" />
              </Lightbox.Trigger>
            </span>
            <span className="text-xs text-neutral-600 dark:text-neutral-400">{people[i]!.name}</span>
          </div>
        ))}
      </div>
      <Viewer frames={frames} setFrame={setFrame} />
    </Lightbox.Root>
  )
}
```
