<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Truong Giang</title>
    <description>The latest articles on DEV Community by Truong Giang (@axyl1410).</description>
    <link>https://dev.to/axyl1410</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012371%2F402af99c-ddfd-452f-9276-b1d805d4210d.jpg</url>
      <title>DEV Community: Truong Giang</title>
      <link>https://dev.to/axyl1410</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/axyl1410"/>
    <language>en</language>
    <item>
      <title>prefers-reduced-motion in React: 5 production patterns beyond duration: 0</title>
      <dc:creator>Truong Giang</dc:creator>
      <pubDate>Sat, 11 Jul 2026 19:07:04 +0000</pubDate>
      <link>https://dev.to/axyl1410/prefers-reduced-motion-in-react-5-production-patterns-beyond-duration-0-333m</link>
      <guid>https://dev.to/axyl1410/prefers-reduced-motion-in-react-5-production-patterns-beyond-duration-0-333m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffu2qqib08iurvkqq3znq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffu2qqib08iurvkqq3znq.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://ui.soralabs.io.vn" rel="noopener noreferrer"&gt;Sora UI&lt;/a&gt; — a small React component library: animated cards, custom cursors, scroll effects, the stuff people reach for to make a landing page feel alive. It's grown to 24 published motion primitives, all built on either GSAP or Motion, and both of those libraries will happily animate someone straight into a migraine if you let them.&lt;/p&gt;

&lt;p&gt;Grep &lt;code&gt;prefers-reduced-motion&lt;/code&gt; across all 24 components and every single one touches it somewhere.&lt;/p&gt;

&lt;p&gt;100% coverage sounds like a clean stat to open a post with. In practice it's not saying much. "Touches it" and "handles it correctly" turn out to be two very different bars, and most of this post is about the gap between them.&lt;/p&gt;

&lt;p&gt;I've written enough of these primitives now that I don't want to do the usual thing — "here's the media query, add it to your CSS" — and call it a day. What I actually want to get into is what breaks when you treat reduced motion as a checkbox instead of a design constraint, and the five different shapes the fix ends up taking once you're doing this across two dozen real components instead of one demo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who this is actually for
&lt;/h2&gt;

&lt;p&gt;This isn't a nice-to-have preference the way dark mode is. Up to 35% of adults over 40 in the US have experienced some form of vestibular dysfunction — the inner-ear system that governs balance — and for a meaningful chunk of them, parallax, large-scale transforms, and auto-playing motion trigger real vertigo, nausea, or migraine. It's WCAG Success Criterion 2.3.3 for a reason. Not an edge case you accommodate to be polite. A documented, physiological failure mode of the thing you shipped.&lt;/p&gt;

&lt;p&gt;The setting lives at the OS level — macOS Accessibility, Windows "Show animations," Android's remove-animations toggle — and the browser exposes it as a media query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole CSS version, one line. The React-with-GSAP-and-Motion version is not one line, and that gap is what this post is actually about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one-line fix that's wrong more often than it's right
&lt;/h2&gt;

&lt;p&gt;Every tutorial leads with the same fix: detect the preference, and if it's set, collapse every transition to &lt;code&gt;duration: 0&lt;/code&gt;. Fine sometimes. Wrong a lot of the time, because it assumes the animation was decorative — that underneath the motion there's a "finished" state that was always going to look fine once the tween settled.&lt;/p&gt;

&lt;p&gt;That assumption breaks the moment the sequence itself &lt;em&gt;is&lt;/em&gt; the content. One of the primitives, &lt;code&gt;StickyScrollCards&lt;/code&gt; — a stack of cards that pins to the viewport and flips through in sequence as you scroll — does enter → flip → dismiss across one scroll-linked pin. There's no honest "un-animated version" of that — "cards taking turns" isn't decorating a static layout, it is the layout. Set every tween's duration to &lt;code&gt;0&lt;/code&gt; there and you haven't disabled the animation, you've teleported the DOM through three overlapping states in one frame. Probably garbage on screen.&lt;/p&gt;

&lt;p&gt;What the component actually does is compute the state the sequence would have ended in, and set it directly. &lt;code&gt;refs&lt;/code&gt; holds references to the card DOM elements, &lt;code&gt;cardCount&lt;/code&gt; is how many cards are in the stack, and &lt;code&gt;timing&lt;/code&gt; is a config object with the angles each dismissed card should end up rotated to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyReducedMotionEndState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cardCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rotationY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backEls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dismissOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cardCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;angleAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dismissTiltAngles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dismissOrder&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;rotationY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-250%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gsap.set()&lt;/code&gt; is the key detail — it writes the properties immediately, no tween, no frames in between. Everything on the right ends up looking exactly like the last frame of the full animation would have, minus the two seconds it took to get there.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ScrollTrigger&lt;/code&gt; — the GSAP plugin that ties an animation's progress to how far the user has scrolled — never even gets created for reduced-motion users. &lt;code&gt;mountCards&lt;/code&gt; sets the end state, then hits &lt;code&gt;if (prefersReducedMotion) { ...; return; }&lt;/code&gt; before it ever reaches the &lt;code&gt;ScrollTrigger.create()&lt;/code&gt; call further down. This is what "just add a media query" glosses over: reduced motion isn't "same result, faster." It's "same destination, no journey." All the card copy stays in the DOM either way, nothing gets removed, so screen readers and page-find see everything regardless — the only thing that changes is whether getting there is staged as a performance.&lt;/p&gt;

&lt;p&gt;Full disclosure, since a post about auditing reduced-motion handling should probably survive its own audit: that's what the component does &lt;em&gt;now&lt;/em&gt;. It's not what the component did an hour ago, when I sat down to use it as this section's example.&lt;/p&gt;

&lt;p&gt;The effect used to be gated like this, one level up from everything you just read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shouldAnimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cardCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;useGSAP&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;shouldAnimate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...mountCards, which is the only place applyReducedMotionEndState gets called&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shouldAnimate&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; the moment &lt;code&gt;prefersReducedMotion&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; — which means the whole &lt;code&gt;useGSAP&lt;/code&gt; callback returned on line one, before &lt;code&gt;mountCards&lt;/code&gt; was even defined, let alone called. &lt;code&gt;applyReducedMotionEndState&lt;/code&gt; — the function I just walked through two paragraphs ago as the "honest" way to handle this — was dead code. Unreachable. Every reduced-motion visitor who ever loaded this component got the cards in whatever state they default to with zero transforms applied: front and back stacked directly on top of each other at dead center, unrotated, all facing forward. Not "same destination, no journey." Just a pile.&lt;/p&gt;

&lt;p&gt;The fix was one variable: gate the effect on &lt;code&gt;hasCards = cardCount &amp;gt; 0&lt;/code&gt; only, and let &lt;code&gt;mountCards&lt;/code&gt; decide what to do about &lt;code&gt;prefersReducedMotion&lt;/code&gt; internally, where the reduced-motion branch had been sitting the entire time, fully written, correctly written, and never once executed. I didn't catch it by testing the component. I caught it because I was about to hold it up as the good example in a post about not trusting components that merely &lt;em&gt;look&lt;/em&gt; like they handle this correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five strategies, pulled from real components
&lt;/h2&gt;

&lt;p&gt;Once "duration: 0" stops being the universal answer, you're deciding per-component, based on what the motion is actually for. Here's how the 24 primitives in this library sorted out in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bail and render nothing.&lt;/strong&gt; &lt;code&gt;CustomCursor&lt;/code&gt; replaces the system pointer with a custom animated shape that follows your mouse — pure decoration. There's no reduced version of a custom cursor. There's just off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useReducedMotion&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isCoarsePointer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCoarsePointer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isCoarsePointer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;appearance&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth noticing it's bundled with the coarse-pointer check, not standalone. Reduced motion and "this is a touchscreen, there's nothing to replace" land in the same branch because they're really the same question underneath: does this effect make sense for this user at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snap to the end state, don't just stop.&lt;/strong&gt; That's &lt;code&gt;StickyScrollCards&lt;/code&gt; above, but the same shape shows up in &lt;code&gt;MagneticCards&lt;/code&gt; too — a grid of cards that drift toward the cursor like they're magnetized. It precomputes the resting layout as a plain style object instead of letting GSAP's inertia physics settle there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;staticCardStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MagneticCardsLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translate(-50%, -50%) translate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) rotate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;deg)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;zIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rest&lt;/code&gt; is just the x/y/rotation values each card would eventually settle at if you let the magnetic pull run its course. Instead of computing those through physics, the function returns a plain CSS &lt;code&gt;transform&lt;/code&gt; string with the destination baked in — no simulation, just the answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;staticCardStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No GSAP tween runs for these cards at all when this branch is taken. The component skips the animation library entirely rather than asking it to animate at zero duration — a small but real difference in what actually executes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collapse the transition object, let the library keep doing its job.&lt;/strong&gt; For Motion-based components, &lt;code&gt;useReducedMotion()&lt;/code&gt; plus a resolver function does the same thing more declaratively. &lt;code&gt;Highlight&lt;/code&gt; — a pill-shaped background that slides between tabs or nav items to mark the active one — does it at the transition level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Transition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;transition&lt;/code&gt; here is Motion's config object — the thing that normally says "ease this over 300ms with a spring curve." The function just swaps that whole object out for &lt;code&gt;{ duration: 0 }&lt;/code&gt; when reduced motion is on, and Motion takes it from there — no manual property-setting needed, unlike the GSAP examples above.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;duration: 0&lt;/code&gt; is genuinely fine here, because what's moving is a small active-state indicator — a tab underline, a pill background — and "appear instantly in the right spot" is a faithful reduced version of "slide there." Same code shape as &lt;code&gt;Highlight&lt;/code&gt;'s neighbor components, different verdict, because the thing moving was always decorating a result here, not constituting one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reduce complexity, not just duration.&lt;/strong&gt; This is the one that actually surprised me, because it's less about removing motion and more about removing cost. &lt;code&gt;ProgressiveBlur&lt;/code&gt; stacks up to 16 &lt;code&gt;backdrop-filter&lt;/code&gt; layers to fake a smooth blur gradient — each layer is a real paint cost — and reduced motion drops it to one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_LAYERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// backdrop-filter is a per-layer paint cost, so reduced motion drops straight&lt;/span&gt;
&lt;span class="c1"&gt;// to a single band — same silhouette, a sixth of the paints.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REDUCED_MOTION_LAYERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some background for why this even needs 16 layers: &lt;code&gt;backdrop-filter: blur()&lt;/code&gt; in CSS applies one uniform blur strength, but a gradient blur — sharp at the top, heavily blurred at the bottom, like a fade — needs multiple overlapping layers with different blur amounts to fake that gradient, since there's no native CSS property for a &lt;em&gt;graduated&lt;/em&gt; blur. Sixteen looks smooth. One paints a lot faster and reduced-motion users don't need the extra smoothness in the first place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InfiniteScrollingImages&lt;/code&gt; — a carousel of images that loops endlessly and drifts on its own — does the same to its visible-card count, and kills autoplay and keyboard momentum scrolling outright, not just makes them instant. Turns out "this person doesn't want a lot of movement" correlates with "this person also doesn't need 16 blur layers fighting for main-thread time" — reduced motion as a stand-in for reduced visual complexity generally, not only reduced animation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't trust a one-time check.&lt;/strong&gt; Every strategy above reads the preference through a hook, not a single &lt;code&gt;matchMedia(...).matches&lt;/code&gt; call at mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePrefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPrefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPrefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;People flip this setting mid-session more than you'd guess — a demo, a screen-share, somebody else's laptop with different defaults. &lt;code&gt;addEventListener("change", ...)&lt;/code&gt; means toggling it in OS settings updates every mounted primitive, no reload needed. A &lt;code&gt;matches&lt;/code&gt; check with no listener isn't wrong on day one. It's wrong the first time someone changes their mind without refreshing the tab.&lt;/p&gt;

&lt;p&gt;Which, as it happens, is the exact bug sitting in &lt;code&gt;Skeleton&lt;/code&gt; — a loading placeholder that pulses gently while content loads — right now. It doesn't import the shared hook. It has its own inline version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prefersReducedMotion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Called once, inside an effect, the moment loading flips to &lt;code&gt;false&lt;/code&gt;. No listener, no re-render on change. Low stakes in this particular spot — the read only matters at the exact instant a skeleton resolves to content, and nobody's toggling OS accessibility settings in that half-second window — but it's the textbook version of the mistake, sitting in the same registry that gets it right everywhere else. Found it writing this post. Not writing a test for it, which is a little embarrassing to admit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why not just use the animation library's own hook
&lt;/h2&gt;

&lt;p&gt;Motion ships &lt;code&gt;useReducedMotion()&lt;/code&gt;. GSAP ships &lt;code&gt;gsap.matchMedia()&lt;/code&gt; for the same purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.box&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are genuinely fine, and the components in this library that only ever touch Motion do reach for &lt;code&gt;useReducedMotion()&lt;/code&gt; directly instead of the shared hook — no reason to reinvent it there. But this library has primitives on GSAP, on Motion, sometimes both in the same file, and none of that should care which library happens to be underneath. One hook, one boolean, works the same whether the caller is &lt;code&gt;gsap.set&lt;/code&gt; or &lt;code&gt;motion.div&lt;/code&gt;'s &lt;code&gt;animate&lt;/code&gt; prop. Past a couple of components split across two motion libraries, "the preference" needs to be a boundary you own — not something each library independently re-derives for you every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gotcha I haven't hit yet, but will
&lt;/h2&gt;

&lt;p&gt;Everything above collapses &lt;code&gt;duration&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt;, which is correct for &lt;code&gt;gsap.set&lt;/code&gt; — that's not an animation, it's an instant property write. But &lt;code&gt;duration: 0&lt;/code&gt; on a &lt;em&gt;tweened&lt;/em&gt; transition that something else listens for the completion of — a &lt;code&gt;transitionend&lt;/code&gt; handler, an &lt;code&gt;onComplete&lt;/code&gt; gating a state change — and some browsers just silently drop that event for a truly zero-length transition. Usual fix is &lt;code&gt;0.01ms&lt;/code&gt; instead of &lt;code&gt;0&lt;/code&gt;: long enough the event still fires, short enough nobody perceives it as motion. None of the 24 currently chain logic off a reduced-motion transition's completion, so it hasn't bitten anyone yet. First thing I'd check the day a &lt;code&gt;prefersReducedMotion&lt;/code&gt; branch grows an &lt;code&gt;onComplete&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd actually change
&lt;/h2&gt;

&lt;p&gt;No user-facing override exists. Everything here follows the OS setting exactly, which is the right default, but a small in-app "reduce motion" toggle that overrides the media query would be more resilient — right now someone who wants full motion sitewide but reduced motion in the one app that triggers them has no way to say so short of flipping an OS-wide setting. GSAP's own docs recommend this pattern for anything motion-forward, for what it's worth.&lt;/p&gt;

&lt;p&gt;Coverage isn't audited, it's grepped. "24 of 24 primitives touch it" is a fact I know because I ran a grep five minutes ago, not because a lint rule or a test fails when a new primitive ships a &lt;code&gt;gsap.to()&lt;/code&gt; with no &lt;code&gt;prefersReducedMotion&lt;/code&gt; branch next to it. And grepping doesn't even catch the bad kind of "touches it" — &lt;code&gt;StickyScrollCards&lt;/code&gt; matched every grep I threw at it, imported the hook, wrote a whole dedicated end-state function, and none of that stopped it from being dead code for however long it's been shipping. &lt;code&gt;Skeleton&lt;/code&gt;'s unlistened check is the same species of problem, just lower stakes. "Every component mentions it," "every component's reduced-motion branch is reachable," and "every component gets it right" are three different claims, and grep only verifies the first one.&lt;/p&gt;

&lt;p&gt;And honestly, the five strategies aren't formalized anywhere. Which one a new primitive gets is a judgment call right now, made per-component, and that's fine at 24 and won't stay fine at 100. Probably needs a one-question decision tree — is the motion sequence the content, or is it decorating a result that exists either way — just so the call is faster to make and easier to review later.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;StickyScrollCards&lt;/code&gt; bug specifically suggests something more mechanical than an audit, too. A snapshot test that renders each primitive twice — &lt;code&gt;prefers-reduced-motion: no-preference&lt;/code&gt; and &lt;code&gt;reduce&lt;/code&gt; — and fails if the reduced render is pixel-identical to some &lt;em&gt;other&lt;/em&gt; broken invariant (all cards stacked at the same coordinates is a pretty detectable shape) would've caught this in CI the first time it shipped, instead of by accident, later, while writing a blog post about not trusting components that only look correct.&lt;/p&gt;




&lt;p&gt;None of this is exotic. &lt;code&gt;window.matchMedia&lt;/code&gt;, a &lt;code&gt;useEffect&lt;/code&gt;, a handful of &lt;code&gt;if&lt;/code&gt; statements repeated 24 times with different endings. But "does this component respect reduced motion" turns out to be the wrong question past a handful of components. The real one is what does this component's content actually reduce to — a cursor reduces to nothing, a pill indicator reduces to instant, a card stack reduces to wherever the story would've ended. Same hook underneath all three. Three different answers, because the question was never really about the hook.&lt;/p&gt;

&lt;p&gt;Every component discussed here — &lt;code&gt;StickyScrollCards&lt;/code&gt;, &lt;code&gt;MagneticCards&lt;/code&gt;, &lt;code&gt;Highlight&lt;/code&gt;, &lt;code&gt;ProgressiveBlur&lt;/code&gt;, &lt;code&gt;CustomCursor&lt;/code&gt;, &lt;code&gt;Skeleton&lt;/code&gt;, and the shared &lt;code&gt;usePrefersReducedMotion&lt;/code&gt; hook itself — is open source in the &lt;a href="https://ui.soralabs.io.vn" rel="noopener noreferrer"&gt;Sora UI&lt;/a&gt; registry, &lt;code&gt;npx shadcn add&lt;/code&gt;-able, source included, no package to trust blindly.&lt;/p&gt;

&lt;p&gt;Reduced motion isn't about removing animation.&lt;/p&gt;

&lt;p&gt;It's about deciding what remains when animation disappears.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>react</category>
      <category>learning</category>
    </item>
    <item>
      <title>Cursor Trail Reveal — No GSAP, No Motion (Pure CSS Transitions &amp; requestAnimationFrame)</title>
      <dc:creator>Truong Giang</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:00:11 +0000</pubDate>
      <link>https://dev.to/axyl1410/cursor-trail-reveal-no-gsap-no-motion-pure-css-transitions-requestanimationframe-kfi</link>
      <guid>https://dev.to/axyl1410/cursor-trail-reveal-no-gsap-no-motion-pure-css-transitions-requestanimationframe-kfi</guid>
      <description>&lt;p&gt;Every time someone looks at &lt;code&gt;CursorTrailReveal&lt;/code&gt;, the first question is the same: &lt;em&gt;"Framer Motion? GSAP?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Neither. The entire effect — images popping out along the cursor path, horizontal strips wiping open one by one, running smooth at 60fps — is built with &lt;strong&gt;&lt;code&gt;requestAnimationFrame&lt;/code&gt; + CSS transitions + &lt;code&gt;clip-path polygon()&lt;/code&gt;&lt;/strong&gt;. Not a single animation library import.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the effect looks like
&lt;/h2&gt;

&lt;p&gt;Move your cursor over the target area and an image appears at the cursor position, sliding gently in the direction you're heading. The image doesn't appear all at once — it's &lt;strong&gt;split into 10 horizontal strips&lt;/strong&gt;, each one opening outward from the center with a custom cubic-bezier easing and a small stagger delay from the middle to the edges. When the image ages out, the strips collapse back toward the center in reverse order, and the element is removed from the DOM.&lt;/p&gt;

&lt;p&gt;All of this orchestration runs inside a single &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4whgkegqdnx69spyam92.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4whgkegqdnx69spyam92.jpg" alt="Animated preview of Cursor Trail Reveal effect showing images popping out along the mouse path" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://ui.soralabs.io.vn/components/cursor-trail-reveal" rel="noopener noreferrer"&gt;Try the Live Demo here&lt;/a&gt;&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Let's dive into how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;render() ← requestAnimationFrame loop
  ├── lerp interpolatedMousePos → mousePos
  ├── if distance &amp;gt; threshold AND mouse in container
  │     └── createTrailImage()
  └── removeOldImages()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No React state is updated during animation. All mutable state lives in &lt;code&gt;useRef&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trailRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TrailImageEntry&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;          &lt;span class="c1"&gt;// queue of visible images&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mousePosRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;             &lt;span class="c1"&gt;// current cursor position&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastMousePosRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;         &lt;span class="c1"&gt;// position when last image spawned&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interpolatedMousePosRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// lerped position&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;animationStateRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// requestAnimationFrame ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useRef&lt;/code&gt; here isn't about pointing at DOM elements — it's about storing &lt;strong&gt;mutable state that shouldn't trigger re-renders&lt;/strong&gt;. This is an important pattern for animation in React: everything that belongs to the frame loop has to live outside React state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The render loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isDesktopRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMouseDistance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Pull the interpolated position 10% closer to the real cursor each frame&lt;/span&gt;
  &lt;span class="nx"&gt;interpolatedMousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MathUtils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;interpolatedMousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;mousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mf"&gt;0.1&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;interpolatedMousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MathUtils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;interpolatedMousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;mousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mf"&gt;0.1&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mouseThreshold&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isInTrailContainer&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;createTrailImage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;lastMousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;mousePosRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;removeOldImages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;animationStateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lerp&lt;/strong&gt; (Linear Interpolation) — &lt;code&gt;lerp(a, b, 0.1)&lt;/code&gt; — moves &lt;code&gt;a&lt;/code&gt; 10% toward &lt;code&gt;b&lt;/code&gt; every frame. At 60fps this creates a position that lags behind the real cursor. This is why images don't spawn exactly where your cursor is but at a point chasing it — giving the trail a sense of physical weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mouseThreshold&lt;/code&gt;&lt;/strong&gt; is the minimum distance the cursor must travel since the last spawn before a new image is created. Default is 150px. Without this guard, a new image would spawn every frame and the browser would be creating hundreds of DOM elements per second.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Clip-path polygon — the core of the wipe
&lt;/h2&gt;

&lt;p&gt;This is the interesting part. Each image is divided into &lt;strong&gt;10 horizontal strips&lt;/strong&gt;, each covering 10% of the height (&lt;code&gt;0%–10%&lt;/code&gt;, &lt;code&gt;10%–20%&lt;/code&gt;, ..., &lt;code&gt;90%–100%&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Each strip is a &lt;code&gt;div&lt;/code&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;backgroundColor: maskColor&lt;/code&gt; (the covering layer)&lt;/li&gt;
&lt;li&gt;A child &lt;code&gt;div&lt;/code&gt; with &lt;code&gt;backgroundImage&lt;/code&gt; set to the actual photo&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;clipPath&lt;/code&gt; that starts with &lt;strong&gt;zero width at the center&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Strip i, initial state — width = 0, pinned at center (50%)&lt;/span&gt;
&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`polygon(
  50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,   // top-left
  50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,   // top-right  (same x → zero width)
  50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,     // bottom-right
  50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%      // bottom-left
)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, one &lt;code&gt;requestAnimationFrame&lt;/code&gt; tick later — giving the browser a chance to commit the initial layout — the clip-path transitions to &lt;strong&gt;full width&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;maskLayers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distanceFromMiddle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;distanceFromMiddle&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staggerIn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// staggerIn = 100ms&lt;/span&gt;

    &lt;span class="nf"&gt;scheduleTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`polygon(
        0%   &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
        100% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
        100% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
        0%   &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%
      )`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;distanceFromMiddle&lt;/code&gt; measures how far each strip is from the center (strips 4 and 5 have the smallest value). Strips near the middle open &lt;strong&gt;first&lt;/strong&gt;, strips at the edges open &lt;strong&gt;last&lt;/strong&gt; — producing the outward wipe from center to edge.&lt;/p&gt;

&lt;p&gt;The CSS transition does the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`clip-path &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inDuration&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// easing: "cubic-bezier(0.87, 0, 0.13, 1)" — sharp in-out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JavaScript is computing intermediate states. The browser interpolates &lt;code&gt;clip-path&lt;/code&gt; from &lt;code&gt;50%&lt;/code&gt; to &lt;code&gt;0%/100%&lt;/code&gt; on its own, following the cubic-bezier curve.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The slide — CSS transition on the GPU
&lt;/h2&gt;

&lt;p&gt;Images don't teleport to the cursor. Each one is placed at the &lt;strong&gt;interpolated position&lt;/strong&gt; (lagging behind the cursor), then immediately transitions to the real cursor position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// At spawn time: place at the interpolated (lagging) position&lt;/span&gt;
&lt;span class="nx"&gt;imgContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate3d(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, 0)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;imgContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`transform &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slideDuration&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slideEasing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One tick later: target is the real cursor position&lt;/span&gt;
&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;imgContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate3d(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetX&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;targetY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, 0)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... kick off strip animations&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;slideDuration&lt;/code&gt; is 1000ms with &lt;code&gt;slideEasing&lt;/code&gt; set to &lt;code&gt;cubic-bezier(0.25, 0.46, 0.45, 0.94)&lt;/code&gt; — a relaxed ease-out. The image always slides toward where the cursor was heading, which gives the trail a sense of momentum.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;translate3d&lt;/code&gt; promotes the element to a compositor layer. The transition runs entirely on the GPU, never blocking the main thread.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: Exit animation — reversed stagger
&lt;/h2&gt;

&lt;p&gt;When an image exceeds &lt;code&gt;imageLifespan&lt;/code&gt; (1000ms), &lt;code&gt;removeOldImages()&lt;/code&gt; fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;imgToRemove&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maskLayers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distanceFromEdge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// inverse of the enter stagger&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;distanceFromEdge&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staggerOut&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// staggerOut = 25ms (faster)&lt;/span&gt;

  &lt;span class="nf"&gt;scheduleTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`polygon(
      50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
      50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripStart&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
      50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,
      50% &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stripEnd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%
    )`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// collapse back to center&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time &lt;code&gt;distanceFromEdge&lt;/code&gt; is largest at the &lt;strong&gt;center&lt;/strong&gt; — edge strips close first, center strips close last. The stagger is the exact inverse of the enter animation: the wipe contracts inward from the edges toward the center.&lt;/p&gt;

&lt;p&gt;A subtle fade runs alongside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;imageLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`opacity &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outDuration&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;imageLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.25&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image doesn't fade fully to zero — it dims to 25%, because the closing strips are already doing most of the covering work. The result feels like the image has depth rather than just fading away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: Performance details
&lt;/h2&gt;

&lt;p&gt;A few small choices that matter at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preload images on mount:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new Image()&lt;/code&gt; triggers a browser fetch and cache fill. When &lt;code&gt;createTrailImage()&lt;/code&gt; later sets &lt;code&gt;backgroundImage&lt;/code&gt;, the image is already in cache — no flicker, no loading flash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU hints on every element:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;imgContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;willChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translateZ(0)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backfaceVisibility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;willChange: transform&lt;/code&gt; signals the browser to prepare a composite layer ahead of time. &lt;code&gt;translateZ(0)&lt;/code&gt; forces GPU layer creation immediately. &lt;code&gt;backfaceVisibility: hidden&lt;/code&gt; reduces paint cost when multiple layers overlap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;createElement&lt;/code&gt; instead of &lt;code&gt;innerHTML&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every strip and image layer is built with &lt;code&gt;document.createElement&lt;/code&gt; + &lt;code&gt;appendChild&lt;/code&gt;. Using &lt;code&gt;innerHTML&lt;/code&gt; forces the browser to re-parse an HTML string and re-calculate layout for the entire subtree, which is measurably slower when you're creating DOM on every cursor event.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: Reduced motion and mobile
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reduceMotion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reduceMotion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isDesktopRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cleanUpMouseListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startAnimation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;reduceMotion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleReduceMotionChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a one-time check on mount — the listener watches for runtime changes. If a user enables reduced motion in system settings while the page is open, the animation stops immediately and all active trail elements are removed from the DOM.&lt;/p&gt;

&lt;p&gt;Mobile is handled by checking &lt;code&gt;window.innerWidth &amp;gt; desktopBreakpoint&lt;/code&gt;. A cursor trail has no meaning on a touch device, and listening to &lt;code&gt;mousemove&lt;/code&gt; on mobile is wasted work. &lt;code&gt;handleResize&lt;/code&gt; toggles the animation on and off as the viewport changes — including when DevTools opens and shrinks the window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why not Framer Motion or GSAP?
&lt;/h2&gt;

&lt;p&gt;Not because they're bad — GSAP is excellent for complex timelines. But this effect doesn't need them.&lt;/p&gt;

&lt;p&gt;Everything here is &lt;strong&gt;CSS transition + clip-path interpolation&lt;/strong&gt;. The browser engine does the heaviest work. JavaScript has exactly one job: deciding &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;where&lt;/em&gt; to change a &lt;code&gt;clipPath&lt;/code&gt; string.&lt;/p&gt;

&lt;p&gt;When you use GSAP to animate &lt;code&gt;clipPath&lt;/code&gt;, GSAP is also doing exactly this — computing intermediate values and setting them on the style object every frame. The difference is that CSS transitions do it at the compositor thread level, not on the main thread.&lt;/p&gt;

&lt;p&gt;The result: &lt;strong&gt;smaller bundle&lt;/strong&gt;, &lt;strong&gt;less jank risk&lt;/strong&gt;, and code short enough for a new team member to fully understand in ten minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pool DOM elements instead of create/destroy.&lt;/strong&gt; Right now each trail image creates 10+ fresh &lt;code&gt;div&lt;/code&gt; elements and removes them after a second. With fast continuous mouse movement, this creates sustained GC pressure. An object pool that reuses elements would eliminate this entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IntersectionObserver&lt;/code&gt; to pause when off-screen.&lt;/strong&gt; If the component sits in a long page and the user has scrolled past it, the &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop keeps running. Pausing when the component isn't visible is a worthwhile optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Animation API instead of CSS transitions for strips.&lt;/strong&gt; &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API" rel="noopener noreferrer"&gt;WAAPI&lt;/a&gt; allows programmatic control — pause, reverse, seek — while still running on the compositor. For strip animations, this would make it possible to cancel mid-animation if an image is removed earlier than expected, rather than letting the CSS transition finish to a state that no longer exists in the DOM.&lt;/p&gt;




&lt;p&gt;The full source is available at &lt;a href="https://ui.soralabs.io.vn/components/cursor-trail-reveal" rel="noopener noreferrer"&gt;Sora UI Components&lt;/a&gt; — you can install it instantly via our AI-native MCP Server or install it with the shadcn CLI and the file lands in your repo. Open it, read it, change any number you like.&lt;/p&gt;

&lt;p&gt;That's how it's meant to be used.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>react</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
