Layout
Questions that open in place
Open an answer, and its card grows to fit while the questions below slide down to make room.
Two to three working days at home, and five to eight abroad. Each piece is wrapped by hand, so allow a day more around the holidays.
Reduced motion: The cards change size at once, and the chevron flips at once; the answers still fade.
Why
When an answer appears all at once, everything below it jumps, and the reader loses their place. When its card grows and the questions below slide down, the page stays readable while it changes.
Each card is a layout element. Opening one changes its height, and Wardic animates the change as a scale, correcting the card's rounded corners and shadow so they look the same all the way. The question is layout="position", so its text travels rather than stretching, and the answer fades in once there's room for it.
The code
import { AnimatePresence, wardic } from "@wardic/solid"
import { createSignal, For, Show } from "solid-js"
const QUESTIONS = [
{
question: "How long does delivery take?",
answer: "Two to three working days at home, and five to eight abroad. Each piece is wrapped by hand, so allow a day more around the holidays.",
},
{
question: "What if something arrives broken?",
answer: "Send us a photo of it within a week and we'll send another, or refund you in full. You don't need to send the pieces back.",
},
{
question: "Can I return a piece?",
answer: "Within 30 days, unused and in its box. We'll email you a label, and refund you once it's back with us.",
},
{
question: "Are the pieces food safe?",
answer: "Everything glazed inside is food safe. The burnished and unglazed pieces are for flowers or for looking at: each product's details say which.",
},
] as const
// An answer fades in once its card has started to grow, and out quickly as it closes.
const answer = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { type: "tween", duration: 0.2, delay: 0.1 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
// The chevron turns over as its answer opens, on a short tween: a rotate's default is a lively
// spring that swings past and back, which a chevron shouldn't (ADR-0051). With no `type`, a
// `duration` makes a tween, eased out.
const chevron = { shut: { rotate: 0 }, open: { rotate: 180 } } as const
export default function DeliveryFaq() {
const [open, setOpen] = createSignal(0)
const toggle = (index: number) => {
if (open() === index) setOpen(-1)
else setOpen(index)
}
const stateOf = (index: number) => {
if (open() === index) return "open"
return "shut"
}
return (
<ul class="faq">
<For each={QUESTIONS}>
{(item, index) => (
// Each item grows as its answer opens: layout animates its size, its rounded corners and
// shadow kept as they are while it scales, and the items below slide down to make room.
<wardic.li class="faq-item" layout>
<wardic.button class="faq-question" layout="position" aria-expanded={open() === index()} onClick={() => toggle(index())}>
<span>{item.question}</span>
{/* initial={false}: the answer open when the page loads has its chevron turned already. */}
<wardic.span class="chevron" variants={chevron} initial={false} animate={stateOf(index())} transition={{ duration: 0.2 }} aria-hidden="true">
⌄
</wardic.span>
</wardic.button>
{/* popLayout: a closing answer leaves the flow at once, so its card shrinks while it fades. */}
<AnimatePresence initial={false} mode="popLayout">
<Show when={open() === index()}>
<wardic.div class="faq-answer" layout="position" {...answer}>
<p>{item.answer}</p>
</wardic.div>
</Show>
</AnimatePresence>
</wardic.li>
)}
</For>
</ul>
)
}
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
const QUESTIONS = [
{
question: "How long does delivery take?",
answer: "Two to three working days at home, and five to eight abroad. Each piece is wrapped by hand, so allow a day more around the holidays.",
},
{
question: "What if something arrives broken?",
answer: "Send us a photo of it within a week and we'll send another, or refund you in full. You don't need to send the pieces back.",
},
{
question: "Can I return a piece?",
answer: "Within 30 days, unused and in its box. We'll email you a label, and refund you once it's back with us.",
},
{
question: "Are the pieces food safe?",
answer: "Everything glazed inside is food safe. The burnished and unglazed pieces are for flowers or for looking at: each product's details say which.",
},
] as const
// An answer fades in once its card has started to grow, and out quickly as it closes.
const answer = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { type: "tween", duration: 0.2, delay: 0.1 } },
exit: { opacity: 0, transition: { type: "tween", duration: 0.1 } },
} as const
// The chevron turns over as its answer opens, on a short tween: a rotate's default is a lively
// spring that swings past and back, which a chevron shouldn't. With no `type`, a `duration` makes a
// tween, eased out.
const chevron = { shut: { rotate: 0 }, open: { rotate: 180 } } as const
export default function DeliveryFaq() {
const [open, setOpen] = useState(0)
return (
<ul className="faq">
{QUESTIONS.map((item, index) => (
// Each item grows as its answer opens: layout animates its size, its rounded corners and
// shadow kept as they are while it scales, and the items below slide down to make room.
<motion.li key={item.question} className="faq-item" layout>
<motion.button className="faq-question" layout="position" aria-expanded={open === index} onClick={() => setOpen(open === index ? -1 : index)}>
<span>{item.question}</span>
{/* initial={false}: the answer open when the page loads has its chevron turned already. */}
<motion.span className="chevron" variants={chevron} initial={false} animate={open === index ? "open" : "shut"} transition={{ duration: 0.2 }} aria-hidden="true">
⌄
</motion.span>
</motion.button>
{/* popLayout: a closing answer leaves the flow at once, so its card shrinks while it fades. */}
<AnimatePresence initial={false} mode="popLayout">
{open === index && (
<motion.div className="faq-answer" layout="position" {...answer}>
<p>{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</motion.li>
))}
</ul>
)
}