Three walking route cards, each with a drawn elevation profile, a name, an area and a difficulty badge. A 'Show trail details' button opens the card downwards on a spring to reveal distance, climb and time tiles that rise in one after another, a short description and a save button. Cards open independently and the others keep their place because the grid aligns to the top. The first card opens by itself just after load. On phones the cards stack and each opens in line.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/disclosure)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const trails = [
{
id: "ridge",
name: "Heron Ridge Loop",
area: "Calder Valley",
grade: "Moderate",
gradeTone: "bg-amber-100 text-amber-900",
profile: [12, 18, 34, 58, 72, 66, 80, 62, 40, 30, 22, 12],
sky: "#ccfbf1",
hill: "#0f766e",
stats: [
{ label: "Distance", value: "11.4 km" },
{ label: "Climb", value: "540 m" },
{ label: "Time", value: "3 h 40" },
],
notes: "Steep for the first hour, then an open ridge with views both ways. Boggy after rain near the tarn. Park at the mill.",
},
{
id: "coast",
name: "Saltmarsh Boardwalk",
area: "Wrenfield Coast",
grade: "Easy",
gradeTone: "bg-emerald-100 text-emerald-900",
profile: [10, 12, 11, 14, 12, 16, 14, 12, 15, 12, 11, 10],
sky: "#e0f2fe",
hill: "#0369a1",
stats: [
{ label: "Distance", value: "5.2 km" },
{ label: "Climb", value: "30 m" },
{ label: "Time", value: "1 h 20" },
],
notes: "Flat timber path through reed beds, suitable for pushchairs. Two bird hides on the way. The cafe at the far end shuts at four.",
},
{
id: "peak",
name: "Black Tor Summit",
area: "Arden Fells",
grade: "Hard",
gradeTone: "bg-rose-100 text-rose-900",
profile: [8, 14, 26, 40, 52, 70, 88, 96, 84, 60, 36, 14],
sky: "#ede9fe",
hill: "#5b21b6",
stats: [
{ label: "Distance", value: "16.8 km" },
{ label: "Climb", value: "1,120 m" },
{ label: "Time", value: "6 h 30" },
],
notes: "A full mountain day with a short scramble below the top. Carry a map: the summit plateau is hard to read in cloud.",
},
];
type Trail = (typeof trails)[number];
const SPRING = { type: "spring" as const, stiffness: 190, damping: 24 };
/** A button and a region that opens under it, with height animated on a spring. */
function Disclosure({ open, onToggle, label, children }: { open: boolean; onToggle: () => void; label: (open: boolean) => string; children: ReactNode }) {
const id = useId();
const reduceMotion = useReducedMotion();
return (
<div>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={onToggle}
className="flex h-11 w-full items-center justify-between rounded-xl px-3 text-sm font-semibold text-teal-800 hover:bg-teal-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-600"
>
{label(open)}
<motion.svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4" animate={{ rotate: open ? 180 : 0 }} transition={SPRING}>
<path d="m5 7.5 5 5 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</motion.svg>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={id}
className="overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : SPRING}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function Profile({ trail }: { trail: Trail }) {
const points = trail.profile.map((height, index) => `${(index / (trail.profile.length - 1)) * 320},${130 - height}`);
return (
<svg viewBox="0 0 320 130" preserveAspectRatio="none" aria-hidden="true" className="h-full w-full">
<rect width="320" height="130" fill={trail.sky} />
<circle cx="262" cy="30" r="14" fill="#ffffff" />
<polygon points={`0,130 ${points.join(" ")} 320,130`} fill={trail.hill} opacity="0.3" transform="translate(0 -14)" />
<polygon points={`0,130 ${points.join(" ")} 320,130`} fill={trail.hill} />
<polyline points={points.join(" ")} fill="none" stroke="#ffffff" strokeWidth="2" strokeDasharray="2 6" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
);
}
export default function ExpandableTrailCardsWithSpringDisclosure() {
const reduceMotion = useReducedMotion();
const [openIds, setOpenIds] = useState<string[]>([]);
const [saved, setSaved] = useState<string[]>([]);
const touched = useRef(false);
// Open the first card just after load so the motion is seen once.
useEffect(() => {
const timer = setTimeout(() => {
if (!touched.current) setOpenIds(["ridge"]);
}, reduceMotion ? 0 : 600);
return () => clearTimeout(timer);
}, [reduceMotion]);
const toggle = (id: string) => {
touched.current = true;
setOpenIds((list) => (list.includes(id) ? list.filter((item) => item !== id) : [...list, id]));
};
return (
<section className="bg-slate-100 px-5 py-14 text-slate-900 sm:px-8 sm:py-16">
<div className="mx-auto max-w-5xl">
<p className="text-xs font-semibold uppercase tracking-widest text-teal-800">Footpath Weekly</p>
<h2 className="mt-3 max-w-xl text-3xl font-bold tracking-tight sm:text-5xl">Three walks for a dry Saturday.</h2>
<ul className="mt-9 grid items-start gap-5 md:grid-cols-3">
{trails.map((trail) => {
const open = openIds.includes(trail.id);
const isSaved = saved.includes(trail.id);
return (
<li key={trail.id} className="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-900/5">
<div className="h-36">
<Profile trail={trail} />
</div>
<div className="p-3">
<div className="flex items-start justify-between gap-3 px-3 pt-2">
<div>
<h3 className="text-lg font-bold tracking-tight">{trail.name}</h3>
<p className="text-sm text-slate-600">{trail.area}</p>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${trail.gradeTone}`}>{trail.grade}</span>
</div>
<div className="mt-3">
<Disclosure open={open} onToggle={() => toggle(trail.id)} label={(isOpen) => (isOpen ? "Hide trail details" : "Show trail details")}>
<div className="px-3 pb-3 pt-2">
<dl className="grid grid-cols-3 gap-2">
{trail.stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...SPRING, delay: 0.06 + index * 0.06 }}
className="rounded-xl bg-slate-100 px-2 py-2.5 text-center"
>
<dt className="text-xs text-slate-600">{stat.label}</dt>
<dd className="mt-0.5 text-sm font-bold">{stat.value}</dd>
</motion.div>
))}
</dl>
<motion.p
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...SPRING, delay: 0.26 }}
className="mt-3 text-sm leading-6 text-slate-700"
>
{trail.notes}
</motion.p>
<button
type="button"
aria-pressed={isSaved}
onClick={() => setSaved((list) => (isSaved ? list.filter((item) => item !== trail.id) : [...list, trail.id]))}
className={`mt-4 h-11 w-full rounded-xl text-sm font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 ${
isSaved ? "bg-teal-50 text-teal-900 ring-1 ring-teal-700" : "bg-teal-700 text-white hover:bg-teal-800"
}`}
>
{isSaved ? "Saved to your walks" : "Save this walk"}
</button>
</div>
</Disclosure>
</div>
</div>
</li>
);
})}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/expandable-trail-cards-with-spring-disclosure.json
framer-motion can animate between `height: 0` and `height: "auto"`. It measures the natural height when the element appears and animates to that number, so you do not need to know the content size in advance.
The grid uses `items-start`, so each card is only as tall as its own content. Without it, grid items stretch to the tallest in the row and the closed cards would grow empty space.
An accordion is a single list where the headers are the main content. Here the disclosure is a small part of a larger card, each card opens on its own, and several can be open together.
While the height is between zero and full, the inner content is taller than its wrapper. `overflow-hidden` clips it so it appears to be revealed rather than spilling over the card below.
You can call `toggle` from pointer enter and leave on the card, but keep the button as well. Hover does not exist on touch screens and keyboard users need a control they can focus.
Adapted from Disclosure in Motion Primitives, used under its open-source licence. Licences and credits
Related: Flip Cards with Details on the Back, FAQ Accordion with Working Open and Close, Spotlight Cards with Pointer Light.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.