Layout
A product opens into its detail view
Tap a piece and its card grows into the full view, photo, name and price carried across.
Loading the shop…
Reduced motion: The view opens and closes at once, in place: the boxes don't travel, and the text and backdrop still fade.
Why
A detail view that pops up from nowhere breaks the thread: the eye has to find the product again. When the card itself grows into the view, its photo, name and price travelling to their new places, there's no doubt which piece you opened, and closing it puts it back where it came from.
Each part has a twin: the card in the grid and the detail view share a layoutId, and so do their photos, names and prices. When one leaves and the other arrives, Wardic animates from one's box to the other's. The names and prices are layout="position", so their text changes size at once and only travels, rather than stretching.
The view sits in a position: fixed frame, over a page you may have scrolled. Wardic measures each twin where it's drawn, so the card opens from where you see it, and closes back there, with nothing to mark on the frame.
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 type { Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, wardic } from "@wardic/solid"
import { createSignal, For, onCleanup, onMount, Show, Suspense } from "solid-js"
import { products } from "~/lib/store.ts"
// The detail view's text fades in once the card has grown into it, and out at once when it closes.
const content = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0, transition: { delay: 0.15 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
export default function ProductDetail() {
const all = createAsync(() => products())
const [open, setOpen] = createSignal<Product>()
const close = () => setOpen(undefined)
onMount(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") close()
}
window.addEventListener("keydown", onKey)
onCleanup(() => window.removeEventListener("keydown", onKey))
})
return (
<Suspense fallback={<p class="muted">Loading the shop…</p>}>
<ul class="product-grid">
<For each={all()}>
{(product) => (
<li>
{/* Each part of the card has a twin in the detail view: the same layoutId. */}
<wardic.button class="product-card" layoutId={`card-${product.id}`} onClick={() => setOpen(product)} aria-label={`Open ${product.name}`}>
<Photo product={product} />
<div class="line">
<wardic.span class="name" layoutId={`name-${product.id}`} layout="position">
{product.name}
</wardic.span>
<wardic.span class="price" layoutId={`price-${product.id}`} layout="position">
{formatPrice(product.price)}
</wardic.span>
</div>
</wardic.button>
</li>
)}
</For>
</ul>
<AnimatePresence>
<Show when={open()}>
{(product) => (
<>
<wardic.div class="backdrop" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} />
{/* A position: fixed frame: the twins meet where each is drawn, however far the page has scrolled. */}
<div class="detail-frame">
<wardic.article class="detail" layoutId={`card-${product().id}`} role="dialog" aria-modal="true" aria-label={product().name}>
<Photo product={product()} />
<div class="detail-body">
<wardic.h2 class="name" layoutId={`name-${product().id}`} layout="position">
{product().name}
</wardic.h2>
<wardic.p class="price" layoutId={`price-${product().id}`} layout="position">
{formatPrice(product().price)}
</wardic.p>
<wardic.div class="detail-text" {...content}>
<p>{product().description}</p>
<ul class="details">
<For each={product().details}>{(detail) => <li>{detail}</li>}</For>
</ul>
<button class="button accent">Add to cart</button>
</wardic.div>
</div>
<button class="icon-button close" onClick={close} aria-label="Close">
✕
</button>
</wardic.article>
</div>
</>
)}
</Show>
</AnimatePresence>
</Suspense>
)
}
function Photo(props: { readonly product: Product }) {
const photo = photoOf(props.product.id)
return (
<wardic.div class="photo" layoutId={`photo-${props.product.id}`}>
<img src={photo.small} srcset={srcsetOf(photo)} sizes="(max-width: 640px) 100vw, 480px" width={photo.width} height={photo.height} alt={props.product.name} />
</wardic.div>
)
}
// A product that opens into its detail view, a server component: it starts the shop's read and
// hands its promise to the client part.
import { listProducts } from "~/lib/store.ts"
import { ProductDetail } from "./Client.tsx"
export default function Demo() {
return <ProductDetail products={listProducts()} />
}
"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 { AnimatePresence, motion } from "motion/react"
import { Suspense, use, useEffect, useState } from "react"
// The detail view's text fades in once the card has grown into it, and out at once when it closes.
const content = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0, transition: { delay: 0.15 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
export function ProductDetail({ products }: { readonly products: Promise<ReadonlyArray<Product>> }) {
return (
<Suspense fallback={<p className="muted">Loading the shop…</p>}>
<Shop products={products} />
</Suspense>
)
}
function Shop(props: { readonly products: Promise<ReadonlyArray<Product>> }) {
const products = use(props.products)
const [open, setOpen] = useState<Product | null>(null)
const close = () => setOpen(null)
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") close()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
return (
<>
<ul className="product-grid">
{products.map((product) => (
<li key={product.id}>
{/* Each part of the card has a twin in the detail view: the same layoutId. */}
<motion.button className="product-card" layoutId={`card-${product.id}`} onClick={() => setOpen(product)} aria-label={`Open ${product.name}`}>
<Photo product={product} />
<div className="line">
<motion.span className="name" layoutId={`name-${product.id}`} layout="position">
{product.name}
</motion.span>
<motion.span className="price" layoutId={`price-${product.id}`} layout="position">
{formatPrice(product.price)}
</motion.span>
</div>
</motion.button>
</li>
))}
</ul>
<AnimatePresence>
{open && <motion.div key="backdrop" className="backdrop" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} />}
{open && (
// A position: fixed frame: the twins meet where each is drawn.
<div key={open.id} className="detail-frame">
<motion.article className="detail" layoutId={`card-${open.id}`} role="dialog" aria-modal="true" aria-label={open.name}>
<Photo product={open} />
<div className="detail-body">
<motion.h2 className="name" layoutId={`name-${open.id}`} layout="position">
{open.name}
</motion.h2>
<motion.p className="price" layoutId={`price-${open.id}`} layout="position">
{formatPrice(open.price)}
</motion.p>
<motion.div className="detail-text" {...content}>
<p>{open.description}</p>
<ul className="details">
{open.details.map((detail) => (
<li key={detail}>{detail}</li>
))}
</ul>
<button className="button accent">Add to cart</button>
</motion.div>
</div>
<button className="icon-button close" onClick={close} aria-label="Close">
✕
</button>
</motion.article>
</div>
)}
</AnimatePresence>
</>
)
}
function Photo({ product }: { readonly product: Product }) {
const photo = photoOf(product.id)
return (
<motion.div className="photo" layoutId={`photo-${product.id}`}>
<img src={photo.small} srcSet={srcsetOf(photo)} sizes="(max-width: 640px) 100vw, 480px" width={photo.width} height={photo.height} alt={product.name} />
</motion.div>
)
}