<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BrandingUp Studio]]></title><description><![CDATA[BrandingUp Studio]]></description><link>https://brandingup.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6acba0616f3dfd90c8b9efc7/f855515b-dac6-4bc3-867f-bc6cb4f50018.png</url><title>BrandingUp Studio</title><link>https://brandingup.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 09:21:20 GMT</lastBuildDate><atom:link href="https://brandingup.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[5 Framer Motion patterns I used building 5 React templates (with code)]]></title><description><![CDATA[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…]]></description><link>https://brandingup.hashnode.dev/5-framer-motion-patterns-i-used-building-5-react-templates-with-code</link><guid isPermaLink="true">https://brandingup.hashnode.dev/5-framer-motion-patterns-i-used-building-5-react-templates-with-code</guid><category><![CDATA[React]]></category><category><![CDATA[framer-motion]]></category><category><![CDATA[CSS]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[BrandingUp Studio]]></dc:creator><pubDate>Sun, 11 Oct 2026 14:49:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acba0616f3dfd90c8b9efc7/56204bbb-1954-4abd-8037-c20d613983a1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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 <code>content.js</code> per template for all the copy.</p>
<p>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.</p>
<h2>1. Stacking cards with <code>sticky</code> + <code>useScroll</code></h2>
<p>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.</p>
<p>The trick is that the layout does most of the work. Each card is <code>position: sticky</code>, so the browser already stacks them. Framer Motion only adds the "pushed back" feel:</p>
<pre><code class="language-jsx">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 (
    &lt;div className="cs-slot"&gt;
      &lt;motion.article className="cs-card" style={{ scale, top: 108 + index * 16 }}&gt;
        &lt;motion.div style={{ opacity: dim }}&gt;{/* card content */}&lt;/motion.div&gt;
      &lt;/motion.article&gt;
    &lt;/div&gt;
  )
}

export default function CourseStack({ items }) {
  const ref = useRef(null)
  const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end end'] })
  return (
    &lt;div className="cs" ref={ref}&gt;
      {items.map((item, i) =&gt; (
        &lt;Card key={item.no} item={item} index={i} total={items.length} progress={scrollYProgress} /&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<pre><code class="language-css">.cs-slot { min-height: 78vh; }                 /* scroll distance per card */
.cs-card { position: sticky; transform-origin: center top; }
</code></pre>
<p>Things I got wrong the first time:</p>
<ul>
<li><strong>The last card needs a range that never starts.</strong> It has no "next card", so I send its range to <code>2 → 3</code> (outside 0–1 progress) and it stays sharp. Without that it faded for no reason.</li>
<li><strong>Stagger the <code>top</code> offset</strong> (<code>top: 108 + index * 16</code>) so the edges of buried cards peek out like a stack of paper.</li>
<li>Scale and opacity are the only animated properties, so scrolling stays cheap.</li>
</ul>
<h2>2. Shared-element transitions with <code>layoutId</code></h2>
<p>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 <code>layoutId</code>:</p>
<pre><code class="language-jsx">// in the list row
&lt;Plate layoutId={'dish-' + item.id} className="mn-thumb" /&gt;
&lt;motion.span layoutId={'dish-t-' + item.id}&gt;{item.name}&lt;/motion.span&gt;

// in the detail overlay (rendered inside &lt;AnimatePresence&gt;)
&lt;Plate layoutId={'dish-' + active.id} className="mn-detail-visual" /&gt;
&lt;motion.h3 layoutId={'dish-t-' + active.id}&gt;{active.name}&lt;/motion.h3&gt;
</code></pre>
<p>When the row unmounts and the overlay mounts with the same <code>layoutId</code>, Framer Motion animates between the two boxes. I use one shared spring for everything so it feels consistent:</p>
<pre><code class="language-js">export const spring = { type: 'spring', stiffness: 420, damping: 36 }
</code></pre>
<p>The same idea makes the active nav underline slide between menu items — render <code>&lt;motion.span layoutId="nav-underline" /&gt;</code> only inside the active link.</p>
<p>One gotcha: the <code>layoutId</code> must be unique per item. If two dishes share an id, they will fly into each other.</p>
<h2>3. Scroll-linked 3D tilt for a hero mockup</h2>
<p>On the SaaS template, the dashboard in the hero starts tilted back and straightens as you scroll:</p>
<pre><code class="language-jsx">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])

&lt;div className="hero-stage"&gt;&lt;AppMock style={{ rotateX, y, scale }} /&gt;&lt;/div&gt;
</code></pre>
<pre><code class="language-css">.hero-stage { perspective: 1600px; }
</code></pre>
<p>The detail people forget: <code>perspective</code> goes on the <strong>parent</strong>. Without it <code>rotateX</code> just looks like the box got squashed.</p>
<p>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 <code>content.js</code> as the rest of the page. The chart converts a plain array to SVG points:</p>
<pre><code class="language-js">function toPoints(arr, w, h, pad = 6) {
  const n = arr.length
  return arr.map((v, i) =&gt; {
    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(' ')
}
</code></pre>
<p>and draws the line with <code>pathLength</code>, which brings us to…</p>
<h2>4. A self-drawing SVG circle</h2>
<p>The fashion template shows contact-sheet frames, and the "picked" frames get a red grease-pencil circle that draws itself when scrolled into view:</p>
<pre><code class="language-jsx">&lt;svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"&gt;
  &lt;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 }}
  /&gt;
&lt;/svg&gt;
</code></pre>
<p>Two details matter here. The path is deliberately a little lopsided and overshoots its own start, so it reads as hand-drawn. And <code>preserveAspectRatio="none"</code> lets the circle stretch to any frame ratio, which would normally make the stroke fat on one axis — <code>vectorEffect="non-scaling-stroke"</code> keeps the line width constant while the shape stretches.</p>
<h2>5. A magnetic button with <code>useSpring</code></h2>
<p>The agency template has buttons that lean toward the cursor. Motion values bypass React re-renders, so this stays smooth:</p>
<pre><code class="language-jsx">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) =&gt; {
    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 = () =&gt; { x.set(0); y.set(0) }

  return (
    &lt;motion.div ref={ref} style={{ x: sx, y: sy, display: 'inline-block' }}
      onMouseMove={onMove} onMouseLeave={onLeave}&gt;
      {children}
    &lt;/motion.div&gt;
  )
}
</code></pre>
<p>Usage is <code>&lt;Magnetic&gt;&lt;Link className="btn"&gt;Start&lt;/Link&gt;&lt;/Magnetic&gt;</code>. Note the <code>useReducedMotion()</code> check: if a visitor asked their OS for less motion, the button simply doesn't move. I added that to every template.</p>
<h2>Three CSS bugs from this week</h2>
<p><strong>Grid <code>1fr</code> overflows on mobile.</strong> A long word or a wide child makes a <code>1fr</code> track grow past the viewport, because <code>1fr</code> really means <code>minmax(auto, 1fr)</code>. Fix: <code>grid-template-columns: minmax(0, 1fr)</code> and <code>min-width: 0</code> on the children. I now write <code>minmax(0, 1fr)</code> by default.</p>
<p><strong>Outline text is invisible.</strong> <code>-webkit-text-stroke</code> alone does not give you outline text: the glyph fill still paints, or the text vanishes against the background. Clear the fill explicitly:</p>
<pre><code class="language-css">.cs-watermark {
  color: transparent;
  -webkit-text-stroke: 1px var(--brass);
  -webkit-text-fill-color: transparent;
}
</code></pre>
<p><strong>A leftover <code>_redirects</code> file broke SPA routing.</strong> I had a Netlify-style <code>_redirects</code> file in <code>public/</code>. 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.</p>
<p>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.</p>
<h2>A short note</h2>
<p>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:</p>
<ul>
<li>Fashion: <a href="https://vellund-demo.kksj1226.workers.dev">https://vellund-demo.kksj1226.workers.dev</a></li>
<li>Restaurant: <a href="https://ostravi-demo.kksj1226.workers.dev">https://ostravi-demo.kksj1226.workers.dev</a></li>
<li>SaaS: <a href="https://loomvane-demo.kksj1226.workers.dev">https://loomvane-demo.kksj1226.workers.dev</a></li>
<li>Agency: <a href="https://tessamo-demo.kksj1226.workers.dev">https://tessamo-demo.kksj1226.workers.dev</a></li>
<li>Wedding: <a href="https://ivorane-demo.kksj1226.workers.dev">https://ivorane-demo.kksj1226.workers.dev</a></li>
</ul>
<p>They are paid templates (Gumroad: <a href="https://brandingup.gumroad.com">https://brandingup.gumroad.com</a>, Lemon Squeezy: <a href="https://brandingup.lemonsqueezy.com">https://brandingup.lemonsqueezy.com</a>), 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 <code>sticky</code> is the best answer on very short screens.</p>
]]></content:encoded></item></channel></rss>