Over the last few weeks I built five website templates in React 18, Vite 5 and Framer Motion 11 — fashion, restaurant, SaaS, agency and wedding. No Tailwind, no TypeScript, just plain CSS and one content.js per template for all the copy.
Making five of them back to back forced me to reuse the same handful of motion ideas, and to find out which ones survive a 320px phone. Here are five patterns that earned their place, with code adapted from the real components, followed by three layout bugs that taught me something.
1. Stacking cards with sticky + useScroll
The restaurant template has a tasting menu where each course slides up and covers the previous one, which shrinks and dims as it gets buried.
The trick is that the layout does most of the work. Each card is position: sticky, so the browser already stacks them. Framer Motion only adds the "pushed back" feel:
function Card({ item, index, total, progress }) {
const isLast = index === total - 1
// the range in which the NEXT card is covering me
const start = isLast ? 2 : (index + 1) / total
const scale = useTransform(progress, [start, isLast ? 3 : 1], [1, 1 - (total - index - 1) * 0.05])
const dim = useTransform(progress, [start, isLast ? 3 : Math.min(start + 0.25, 1)], [1, 0.55])
return (
<div className="cs-slot">
<motion.article className="cs-card" style={{ scale, top: 108 + index * 16 }}>
<motion.div style={{ opacity: dim }}>{/* card content */}</motion.div>
</motion.article>
</div>
)
}
export default function CourseStack({ items }) {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end end'] })
return (
<div className="cs" ref={ref}>
{items.map((item, i) => (
<Card key={item.no} item={item} index={i} total={items.length} progress={scrollYProgress} />
))}
</div>
)
}
.cs-slot { min-height: 78vh; } /* scroll distance per card */
.cs-card { position: sticky; transform-origin: center top; }
Things I got wrong the first time:
-
The last card needs a range that never starts. It has no "next card", so I send its range to
2 → 3(outside 0–1 progress) and it stays sharp. Without that it faded for no reason. -
Stagger the
topoffset (top: 108 + index * 16) so the edges of buried cards peek out like a stack of paper. - Scale and opacity are the only animated properties, so scrolling stays cheap.
2. Shared-element transitions with layoutId
In the menu, tapping a dish makes its small round plate grow into a big detail view. No coordinates, no measuring — two elements share a layoutId:
// in the list row
<Plate layoutId={'dish-' + item.id} className="mn-thumb" />
<motion.span layoutId={'dish-t-' + item.id}>{item.name}</motion.span>
// in the detail overlay (rendered inside <AnimatePresence>)
<Plate layoutId={'dish-' + active.id} className="mn-detail-visual" />
<motion.h3 layoutId={'dish-t-' + active.id}>{active.name}</motion.h3>
When the row unmounts and the overlay mounts with the same layoutId, Framer Motion animates between the two boxes. I use one shared spring for everything so it feels consistent:
export const spring = { type: 'spring', stiffness: 420, damping: 36 }
The same idea makes the active nav underline slide between menu items — render <motion.span layoutId="nav-underline" /> only inside the active link.
One gotcha: the layoutId must be unique per item. If two dishes share an id, they will fly into each other.
3. Scroll-linked 3D tilt for a hero mockup
On the SaaS template, the dashboard in the hero starts tilted back and straightens as you scroll:
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] })
const rotateX = useTransform(scrollYProgress, [0, 0.5], [12, 0])
const y = useTransform(scrollYProgress, [0, 0.5], [0, -40])
const scale = useTransform(scrollYProgress, [0, 0.5], [0.96, 1])
<div className="hero-stage"><AppMock style={{ rotateX, y, scale }} /></div>
.hero-stage { perspective: 1600px; }
The detail people forget: perspective goes on the parent. Without it rotateX just looks like the box got squashed.
The dashboard itself is not an image. It is about 140 lines of JSX plus a stylesheet, with an SVG chart, driven from the same content.js as the rest of the page. The chart converts a plain array to SVG points:
function toPoints(arr, w, h, pad = 6) {
const n = arr.length
return arr.map((v, i) => {
const x = pad + (i / (n - 1)) * (w - pad * 2)
const y = h - pad - (v / 100) * (h - pad * 2)
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
and draws the line with pathLength, which brings us to…
4. A self-drawing SVG circle
The fashion template shows contact-sheet frames, and the "picked" frames get a red grease-pencil circle that draws itself when scrolled into view:
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<motion.path
d="M8 50 C 6 18, 40 6, 60 7 C 84 8, 96 24, 95 50 C 94 78, 70 95, 44 94 C 20 93, 5 76, 7 52 C 8 40, 18 30, 30 26"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.9, ease: [0.23, 1, 0.32, 1], delay: 0.25 }}
/>
</svg>
Two details matter here. The path is deliberately a little lopsided and overshoots its own start, so it reads as hand-drawn. And preserveAspectRatio="none" lets the circle stretch to any frame ratio, which would normally make the stroke fat on one axis — vectorEffect="non-scaling-stroke" keeps the line width constant while the shape stretches.
5. A magnetic button with useSpring
The agency template has buttons that lean toward the cursor. Motion values bypass React re-renders, so this stays smooth:
export default function Magnetic({ children, strength = 18 }) {
const ref = useRef(null)
const reduce = useReducedMotion()
const x = useMotionValue(0)
const y = useMotionValue(0)
const sx = useSpring(x, { stiffness: 220, damping: 18, mass: 0.4 })
const sy = useSpring(y, { stiffness: 220, damping: 18, mass: 0.4 })
const onMove = (e) => {
if (reduce || !ref.current) return
const r = ref.current.getBoundingClientRect()
x.set(((e.clientX - (r.left + r.width / 2)) / (r.width / 2)) * strength)
y.set(((e.clientY - (r.top + r.height / 2)) / (r.height / 2)) * strength)
}
const onLeave = () => { x.set(0); y.set(0) }
return (
<motion.div ref={ref} style={{ x: sx, y: sy, display: 'inline-block' }}
onMouseMove={onMove} onMouseLeave={onLeave}>
{children}
</motion.div>
)
}
Usage is <Magnetic><Link className="btn">Start</Link></Magnetic>. Note the useReducedMotion() check: if a visitor asked their OS for less motion, the button simply doesn't move. I added that to every template.
Three CSS bugs from this week
Grid 1fr overflows on mobile. A long word or a wide child makes a 1fr track grow past the viewport, because 1fr really means minmax(auto, 1fr). Fix: grid-template-columns: minmax(0, 1fr) and min-width: 0 on the children. I now write minmax(0, 1fr) by default.
Outline text is invisible. -webkit-text-stroke alone does not give you outline text: the glyph fill still paints, or the text vanishes against the background. Clear the fill explicitly:
.cs-watermark {
color: transparent;
-webkit-text-stroke: 1px var(--brass);
-webkit-text-fill-color: transparent;
}
A leftover _redirects file broke SPA routing. I had a Netlify-style _redirects file in public/. When I deployed the demos to Cloudflare Workers, that file conflicted with the Workers SPA routing setting. Pick one routing mechanism per host and delete the other's file.
I also learned something about taste: after putting all five templates side by side, two of them looked too similar and I redesigned them. Comparing finished work at the same scale shows repetition you never see while building one.
A short note
These five templates are what I sell as a solo maker in Seoul (BrandingUp Studio). If you want to see the patterns running rather than reading about them, the live demos are here:
- Fashion: https://vellund-demo.kksj1226.workers.dev
- Restaurant: https://ostravi-demo.kksj1226.workers.dev
- SaaS: https://loomvane-demo.kksj1226.workers.dev
- Agency: https://tessamo-demo.kksj1226.workers.dev
- Wedding: https://ivorane-demo.kksj1226.workers.dev
They are paid templates (Gumroad: https://brandingup.gumroad.com, Lemon Squeezy: https://brandingup.lemonsqueezy.com), but every snippet above is yours to copy. I'd love to hear which pattern you'd have built differently, especially the stacking cards — I'm not sure sticky is the best answer on very short screens.
Top comments (1)
tr.ee/dev-to