Entrances

The product grid

The shop's grid fades and rises in, one card after another.

  • variants
  • staggerChildren
  • initial
  • animate
  • whileHover
Live demo

Loading the shop…

Reduced motion: The cards fade in, still one after another, but don't rise: movement is dropped, opacity stays. Under the pointer they don't lift and their photos don't grow.

Why

A grid that appears all at once is a wall of pictures. Revealing the cards one after another gives the eye a path through them, from the first product to the last, and the page feels like it's arriving rather than loading.

The grid names a variant, and every card under it follows: the grid's staggerChildren delays each card by its place, so the order is the layout's order, whatever the products are. Change the products, and the reveal follows. No card knows its own delay.

The code

Motion's card can't name its hover in whileHover: a variant name there makes the card a variant controller of its own, which stops following the grid's initial and animate, so it would neither enter nor stagger. Its card lifts by a target, whileHover={{ y: -4 }}, which keeps it in the grid's tree, and its photo hears the hover through onHoverStart and onHoverEnd, as state. Wardic passes the grid's names down past a card's own whileHover, and the hover's name on to its photo. And Motion starts the entrance once React has hydrated the grid, where Wardic starts it as the grid streams in, from the CSS it writes into it.

examples/product-grid/Demo.tsx
import { createAsync } from "@solidjs/router"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { wardic } from "@wardic/solid"
import { For, Suspense } from "solid-js"
import { products } from "~/lib/store.ts"

// The grid names a variant; each card under it follows, delayed by its place.
const grid = {
  hidden: {},
  shown: { transition: { staggerChildren: 0.06, delayChildren: 0.1 } },
}

const card = {
  hidden: { opacity: 0, y: 24 },
  shown: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 240, damping: 24 } },
  // Under the pointer the card lifts, and the name passes to its photo, which eases in.
  hover: { y: -4, transition: { type: "spring", stiffness: 400, damping: 28 } },
} as const

const photo = {
  shown: { scale: 1 },
  hover: { scale: 1.05 },
} as const

export default function ProductGrid() {
  const all = createAsync(() => products())
  return (
    <Suspense fallback={<p class="muted">Loading the shop…</p>}>
      <wardic.ul class="product-grid" variants={grid} initial="hidden" animate="shown">
        <For each={all()}>
          {(product) => {
            const image = photoOf(product.id)
            return (
              <wardic.li class="product-card" variants={card} whileHover="hover">
                <div class="photo">
                  <wardic.img variants={photo} src={image.small} srcset={srcsetOf(image)} sizes="(max-width: 640px) 50vw, 240px" width={image.width} height={image.height} alt={product.name} />
                </div>
                <div class="line">
                  <span class="name">{product.name}</span>
                  <span class="price">{formatPrice(product.price)}</span>
                </div>
                <p class="tagline">{product.tagline}</p>
              </wardic.li>
            )
          }}
        </For>
      </wardic.ul>
    </Suspense>
  )
}
examples/product-grid/Demo.tsx
// The product grid, a server component: it starts the shop's read and hands its promise to the
// client part.
import { listProducts } from "~/lib/store.ts"
import { ProductGrid } from "./Client.tsx"

export default function Demo() {
  return <ProductGrid products={listProducts()} />
}
examples/product-grid/Client.tsx
"use client"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { motion } from "motion/react"
import { Suspense, use, useState } from "react"

// The grid names a variant; each card under it follows, delayed by its place.
const grid = {
  hidden: {},
  shown: { transition: { staggerChildren: 0.06, delayChildren: 0.1 } },
}

const card = {
  hidden: { opacity: 0, y: 24 },
  shown: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 240, damping: 24 } },
} as const

// Under the pointer the card lifts, and its photo eases in. A target, not a variant name: a name in
// `whileHover` would make the card a variant controller of its own, and it would no longer follow
// the grid's `initial` and `animate`, so it would neither enter nor stagger. The photo hears the
// hover through state instead.
const lift = { y: -4, transition: { type: "spring", stiffness: 400, damping: 28 } } as const

export function ProductGrid({ products }: { readonly products: Promise<ReadonlyArray<Product>> }) {
  // The grid waits for the products under <Suspense>, then reveals them.
  return (
    <Suspense fallback={<p className="muted">Loading the shop…</p>}>
      <Grid products={products} />
    </Suspense>
  )
}

function Grid(props: { readonly products: Promise<ReadonlyArray<Product>> }) {
  return (
    <motion.ul className="product-grid" variants={grid} initial="hidden" animate="shown">
      {use(props.products).map((product) => (
        <Card key={product.id} product={product} />
      ))}
    </motion.ul>
  )
}

function Card({ product }: { readonly product: Product }) {
  const [hovered, setHovered] = useState(false)
  const image = photoOf(product.id)
  return (
    <motion.li className="product-card" variants={card} whileHover={lift} onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)}>
      <div className="photo">
        <motion.img
          animate={{ scale: hovered ? 1.05 : 1 }}
          src={image.small}
          srcSet={srcsetOf(image)}
          sizes="(max-width: 640px) 50vw, 240px"
          width={image.width}
          height={image.height}
          alt={product.name}
        />
      </div>
      <div className="line">
        <span className="name">{product.name}</span>
        <span className="price">{formatPrice(product.price)}</span>
      </div>
      <p className="tagline">{product.tagline}</p>
    </motion.li>
  )
}
Run Motion's version →