Layout
Filter the shop, switch the view
Pick a category and the products that stay slide together; switch to a list and every card reshapes into a row.
Loading the shop…
Reduced motion: Movement and resizing happen at once, not over time: the view switches in place, and products that come or go fade.
Why
When a filter takes products away and the rest jump to fill the gaps, it's hard to tell what changed. When the ones leaving fade where they were and the rest slide into their new places, the eye can follow each product to where it ends up.
Each product is a layout element inside an <AnimatePresence mode="popLayout">. One the filter takes out is popped out of the flow at once, so the products after it start sliding into its space while it fades.
Switching between grid and list changes every card's size, and each card sees its own change and animates from where it was. The photos resize with plain layout, their corners kept round as they scale; the text is layout="position", so it travels without stretching.
The code
import { createAsync } from "@solidjs/router"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { CATEGORIES, type Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, wardic } from "@wardic/solid"
import { createSignal, For, Show, Suspense } from "solid-js"
import { products } from "~/lib/store.ts"
const FILTERS = ["All", ...CATEGORIES] as const
type Filter = (typeof FILTERS)[number]
type View = "grid" | "list"
// A product a filter brings in grows into place; one it takes out shrinks and fades where it was,
// out of the flow (popLayout), while the ones after it slide into its space.
const item = {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
} as const
export default function Browse() {
const all = createAsync(() => products())
const [filter, setFilter] = createSignal<Filter>("All")
const [view, setView] = createSignal<View>("grid")
return (
<div class="shop">
<div class="shop-toolbar">
<div class="chips" role="group" aria-label="Category">
<For each={FILTERS}>
{(name) => (
<button class="chip" aria-pressed={filter() === name} onClick={() => setFilter(name)}>
{name}
</button>
)}
</For>
</div>
{/* Switching the view changes every card's size: each one sees it, and animates from where it was. */}
<div class="segmented" role="group" aria-label="View">
<button aria-pressed={view() === "grid"} onClick={() => setView("grid")}>
Grid
</button>
<button aria-pressed={view() === "list"} onClick={() => setView("list")}>
List
</button>
</div>
</div>
<Suspense fallback={<p class="muted">Loading the shop…</p>}>
<Show when={all()}>
{(list) => (
<ul class="shop-items" data-view={view()}>
{/* initial={false}: the products already there when the page opens don't grow in. */}
<AnimatePresence mode="popLayout" initial={false}>
<For each={list().filter((product) => filter() === "All" || product.category === filter())}>
{(product) => <Item product={product} />}
</For>
</AnimatePresence>
</ul>
)}
</Show>
</Suspense>
</div>
)
}
function Item(props: { readonly product: Product }) {
const photo = photoOf(props.product.id)
return (
<wardic.li class="shop-item" layout {...item}>
{/* The photo changes size between the views: plain layout, its corners kept round as it scales. */}
<wardic.div class="photo" layout>
<img src={photo.small} srcset={srcsetOf(photo)} sizes="(max-width: 640px) 50vw, 240px" width={photo.width} height={photo.height} alt={props.product.name} />
</wardic.div>
{/* Text only travels: layout="position" moves it to its new place without stretching it. */}
<wardic.span class="name" layout="position">
{props.product.name}
</wardic.span>
<wardic.span class="price" layout="position">
{formatPrice(props.product.price)}
</wardic.span>
<wardic.span class="tagline" layout="position">
{props.product.tagline}
</wardic.span>
</wardic.li>
)
}
// The shop, filtered and switched between views, a server component: it starts the shop's read and
// hands its promise to the client part.
import { listProducts } from "~/lib/store.ts"
import { Browse } from "./Client.tsx"
export default function Demo() {
return <Browse products={listProducts()} />
}
"use client"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { CATEGORIES, type Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion } from "motion/react"
import { Suspense, use, useState } from "react"
const FILTERS = ["All", ...CATEGORIES] as const
type Filter = (typeof FILTERS)[number]
type View = "grid" | "list"
// A product a filter brings in grows into place; one it takes out shrinks and fades where it was,
// out of the flow (popLayout), while the ones after it slide into its space.
const item = {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
} as const
export function Browse({ products }: { readonly products: Promise<ReadonlyArray<Product>> }) {
const [filter, setFilter] = useState<Filter>("All")
const [view, setView] = useState<View>("grid")
return (
<div className="shop">
<div className="shop-toolbar">
<div className="chips" role="group" aria-label="Category">
{FILTERS.map((name) => (
<button key={name} className="chip" aria-pressed={filter === name} onClick={() => setFilter(name)}>
{name}
</button>
))}
</div>
{/* Switching the view changes every card's size: React renders each again, and Motion
animates it from where it was. */}
<div className="segmented" role="group" aria-label="View">
<button aria-pressed={view === "grid"} onClick={() => setView("grid")}>
Grid
</button>
<button aria-pressed={view === "list"} onClick={() => setView("list")}>
List
</button>
</div>
</div>
<Suspense fallback={<p className="muted">Loading the shop…</p>}>
<Items products={products} filter={filter} view={view} />
</Suspense>
</div>
)
}
function Items({ filter, view, ...props }: { readonly products: Promise<ReadonlyArray<Product>>; readonly filter: Filter; readonly view: View }) {
const shown = use(props.products).filter((product) => filter === "All" || product.category === filter)
return (
<ul className="shop-items" data-view={view}>
{/* initial={false}: the products already there when the page opens don't grow in. */}
<AnimatePresence mode="popLayout" initial={false}>
{shown.map((product) => (
<Item key={product.id} product={product} />
))}
</AnimatePresence>
</ul>
)
}
function Item({ product }: { readonly product: Product }) {
const photo = photoOf(product.id)
return (
<motion.li className="shop-item" layout {...item}>
{/* The photo changes size between the views: plain layout, its corners kept round as it scales. */}
<motion.div className="photo" layout>
<img src={photo.small} srcSet={srcsetOf(photo)} sizes="(max-width: 640px) 50vw, 240px" width={photo.width} height={photo.height} alt={product.name} />
</motion.div>
{/* Text only travels: layout="position" moves it to its new place without stretching it. */}
<motion.span className="name" layout="position">
{product.name}
</motion.span>
<motion.span className="price" layout="position">
{formatPrice(product.price)}
</motion.span>
<motion.span className="tagline" layout="position">
{product.tagline}
</motion.span>
</motion.li>
)
}