Live values

A lookbook with depth

Scroll the lookbook and each collection's photo drifts slower than its banner and its words faster, as if they sat at different depths.

  • createScroll with target and offset
  • createTransform
  • y in style
Live demo

Vases

Thrown in one piece, glazed to run.

Bowls

For the table, and for keeping things in.

Glass

Blown next door, cut by hand.

Reduced motion: Nothing drifts: the photos and words scroll with their banners.

Why

A lookbook is for browsing, and depth makes it feel like a place: as you move, near things pass faster than far ones. Each banner's photo moves a little slower than the banner, and its words a little faster, so the photo reads as behind and the words in front.

Each banner asks createScroll for its own progress through the panel: with a target and Motion's offset, ["start end", "end start"], it's 0 as the banner's top comes in at the bottom and 1 as its bottom leaves at the top. createTransform maps that to a few dozen pixels of y for the photo and for the words, one each way.

A parallax is motion of your own, so reduced motion is yours to apply: Wardic snaps the animations it runs, and a live value follows what moves it. The demo's transforms give 0 under it.

The code

examples/parallax/Demo.tsx
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { type Collection, LOOKBOOK } from "@wardic/example-storefront-shared/stories"
import { createScroll, createTransform, wardic } from "@wardic/solid"
import { For } from "solid-js"
import { useReducedMotion } from "~/lib/preferences.tsx"

export default function Parallax() {
  let panel!: HTMLDivElement
  return (
    <div class="lookbook" ref={panel} tabindex="0" aria-label="The lookbook">
      <For each={LOOKBOOK}>{(collection) => <Banner collection={collection} panel={() => panel} />}</For>
    </div>
  )
}

function Banner(props: { readonly collection: Collection; readonly panel: () => HTMLElement | undefined }) {
  let banner!: HTMLElement
  const reduced = useReducedMotion()
  // How far the banner has come through the panel: 0 as its top comes in at the bottom, 1 as its
  // bottom leaves at the top.
  const { scrollYProgress } = createScroll({ container: props.panel, target: () => banner, offset: ["start end", "end start"] })
  // The photo moves less than the banner, the words more: depth. Under reduced motion, neither.
  const depth = (travel: number) => (progress: number) => {
    if (reduced()) return 0
    return (progress - 0.5) * travel
  }
  const photoY = createTransform(scrollYProgress, depth(90))
  const wordsY = createTransform(scrollYProgress, depth(-70))
  const photo = photoOf(props.collection.photo)
  return (
    <section class="lookbook-banner" ref={banner}>
      <wardic.img class="lookbook-photo" style={{ y: photoY }} src={photo.large} srcset={srcsetOf(photo)} sizes="(max-width: 640px) 100vw, 720px" alt="" />
      <wardic.div class="lookbook-words" style={{ y: wordsY }}>
        <h3>{props.collection.title}</h3>
        <p>{props.collection.blurb}</p>
      </wardic.div>
    </section>
  )
}
examples/parallax/Demo.tsx
"use client"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { type Collection, LOOKBOOK } from "@wardic/example-storefront-shared/stories"
import { motion, useReducedMotionConfig, useScroll, useTransform } from "motion/react"
import { type RefObject, useRef } from "react"

export default function Parallax() {
  const panel = useRef<HTMLDivElement>(null)
  return (
    <div className="lookbook" ref={panel} tabIndex={0} aria-label="The lookbook">
      {LOOKBOOK.map((collection) => (
        <Banner key={collection.title} collection={collection} panel={panel} />
      ))}
    </div>
  )
}

function Banner({ collection, panel }: { readonly collection: Collection; readonly panel: RefObject<HTMLDivElement | null> }) {
  const banner = useRef<HTMLElement>(null)
  // The system's setting, or the header's switch (`MotionConfig`).
  const reduced = useReducedMotionConfig()
  // How far the banner has come through the panel: 0 as its top comes in at the bottom, 1 as its
  // bottom leaves at the top.
  const { scrollYProgress } = useScroll({ container: panel, target: banner, offset: ["start end", "end start"] })
  // The photo moves less than the banner, the words more: depth. Under reduced motion, neither.
  const depth = (travel: number) => (progress: number) => {
    if (reduced) return 0
    return (progress - 0.5) * travel
  }
  const photoY = useTransform(scrollYProgress, depth(90))
  const wordsY = useTransform(scrollYProgress, depth(-70))
  const photo = photoOf(collection.photo)
  return (
    <section className="lookbook-banner" ref={banner}>
      <motion.img className="lookbook-photo" style={{ y: photoY }} src={photo.large} srcSet={srcsetOf(photo)} sizes="(max-width: 640px) 100vw, 720px" alt="" />
      <motion.div className="lookbook-words" style={{ y: wordsY }}>
        <h3>{collection.title}</h3>
        <p>{collection.blurb}</p>
      </motion.div>
    </section>
  )
}
Run Motion's version →