Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions web/components/SettledUpState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import React from 'react';
import { THEMES } from '../constants';
import { useTheme } from '../contexts/ThemeContext';
import { Confetti } from './ui/Confetti';

export const SettledUpState = () => {
const { style } = useTheme();

return (
<div className="col-span-full flex flex-col items-center justify-center py-20 relative">
<Confetti count={100} />

<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{
type: "spring",
stiffness: 260,
damping: 20,
delay: 0.1
}}
className={`w-24 h-24 flex items-center justify-center mx-auto mb-6 ${
style === THEMES.NEOBRUTALISM
? 'bg-emerald-100 border-2 border-black rounded-none shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]'
: 'bg-emerald-100 dark:bg-emerald-900/20 rounded-full ring-4 ring-emerald-500/20'
}`}
>
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<Check size={48} strokeWidth={3} className={style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500'} />
</motion.div>
</motion.div>

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-center"
>
<h3 className={`text-3xl font-black mb-2 ${style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500'}`}>
All Settled Up!
</h3>
<p className="opacity-60 text-lg">No outstanding balances in this group.</p>
</motion.div>
Comment on lines +1 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

find . -name "package.json" -type f | head -5 | xargs grep -l "framer-motion" 2>/dev/null

Repository: Devasy/splitwiser

Length of output: 79


🏁 Script executed:

cat package.json | grep -A 2 -B 2 "framer-motion"

Repository: Devasy/splitwiser

Length of output: 105


🏁 Script executed:

find . -name "Confetti*" -o -name "confetti*" | head -10

Repository: Devasy/splitwiser

Length of output: 93


🌐 Web query:

framer-motion v11 useReducedMotion API prefers-reduced-motion

💡 Result:

In Framer Motion v11, reduced-motion support is built around the user’s OS/browser setting exposed via the CSS media query (prefers-reduced-motion: reduce). [3]

1) Manual: useReducedMotion()

useReducedMotion() is a hook that returns a boolean indicating whether Reduced Motion is enabled, and it will re-render when the setting changes. [2]

import { useReducedMotion, motion } from "framer-motion" // v11

export function Sidebar({ isOpen }: { isOpen: boolean }) {
  const shouldReduceMotion = useReducedMotion()

  return (
    <motion.div
      animate={
        shouldReduceMotion
          ? { opacity: isOpen ? 1 : 0 } // reduce transforms
          : { x: isOpen ? 0 : "-100%" }
      }
    />
  )
}

[1][2]

2) Automatic (site-wide): <MotionConfig reducedMotion="user" />

If you want Motion to automatically comply across your app, set:

import { MotionConfig } from "framer-motion" // v11

<MotionConfig reducedMotion="user">{children}</MotionConfig>

With "user", Motion will disable transform and layout animations while preserving other animations like opacity. [1]

Sources: Motion accessibility guide + API docs [1][2], MDN prefers-reduced-motion [3].

[1] https://motion.dev/docs/react-accessibility
[2] https://motion.dev/motion/use-reduced-motion/
[3] https://developer.mozilla.org/en-US/docs/Web/CSS/%40media/prefers-reduced-motion


🏁 Script executed:

cat ./web/package.json

Repository: Devasy/splitwiser

Length of output: 768


🏁 Script executed:

cat -n ./web/components/ui/Confetti.tsx

Repository: Devasy/splitwiser

Length of output: 2251


Honor prefers-reduced-motion to improve accessibility for motion-sensitive users.

This component is animation-heavy with entrance animations and confetti effects. Please respect user reduced-motion settings by gating confetti and disabling animations when prefers-reduced-motion is active.

Example approach using `useReducedMotion()` from framer-motion v11
-import { motion } from 'framer-motion';
+import { motion, useReducedMotion } from 'framer-motion';
@@
 export const SettledUpState = () => {
     const { style } = useTheme();
+    const reduceMotion = useReducedMotion();
@@
-            <Confetti count={100} />
+            {!reduceMotion && <Confetti count={100} />}
@@
             <motion.div
-                initial={{ scale: 0, rotate: -180 }}
-                animate={{ scale: 1, rotate: 0 }}
-                transition={{
-                    type: "spring",
-                    stiffness: 260,
-                    damping: 20,
-                    delay: 0.1
-                }}
+                initial={reduceMotion ? false : { scale: 0, rotate: -180 }}
+                animate={{ scale: 1, rotate: 0 }}
+                transition={
+                    reduceMotion
+                        ? { duration: 0 }
+                        : { type: "spring", stiffness: 260, damping: 20, delay: 0.1 }
+                }
@@
             <motion.div
-                initial={{ opacity: 0, scale: 0.5 }}
-                animate={{ opacity: 1, scale: 1 }}
-                transition={{ delay: 0.2 }}
+                initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }}
+                animate={{ opacity: 1, scale: 1 }}
+                transition={reduceMotion ? { duration: 0 } : { delay: 0.2 }}
@@
             <motion.div
-                initial={{ opacity: 0, y: 20 }}
-                animate={{ opacity: 1, y: 0 }}
-                transition={{ delay: 0.3 }}
+                initial={reduceMotion ? false : { opacity: 0, y: 20 }}
+                animate={{ opacity: 1, y: 0 }}
+                transition={reduceMotion ? { duration: 0 } : { delay: 0.3 }}

Also consider adding reduced-motion support to the Confetti component itself so it respects user preferences when used elsewhere.

🤖 Prompt for AI Agents
In `@web/components/SettledUpState.tsx` around lines 1 - 49, The component ignores
user "prefers-reduced-motion" and must respect reduced-motion settings: use
framer-motion's useReducedMotion() inside SettledUpState to detect reduced
motion, pass a flag into Confetti (e.g., reducedMotion or disableAnimation) and
conditionally render Confetti (skip or set count=0) when reduced; for the
motion.div elements (the outer icon wrapper, inner check, and text container)
use the reducedMotion flag to replace animate with initial (or set animate equal
to initial/disable transitions) so no entrance animations run when reducedMotion
is true; update the Confetti component similarly to accept and honor the prop so
it avoids rendering or animating when reducedMotion is true.

</div>
);
};
65 changes: 65 additions & 0 deletions web/components/ui/Confetti.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { motion } from 'framer-motion';
import React, { useEffect, useState } from 'react';
import { COLORS } from '../../constants';

interface ConfettiProps {
count?: number;
}

interface Particle {
id: number;
x: number;
y: number;
color: string;
size: number;
rotation: number;
duration: number;
delay: number;
}
Comment on lines +9 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stabilize particle randomness to avoid jitter on re-renders.

Math.random() in render (x drift and borderRadius) changes on every re-render, so particles can jump or re‑animate unexpectedly. Store those values in Particle so they’re stable for the component’s lifetime.

💡 Proposed fix
 interface Particle {
   id: number;
   x: number;
   y: number;
   color: string;
   size: number;
   rotation: number;
   duration: number;
   delay: number;
+  drift: number;
+  isCircle: boolean;
 }
@@
     const newParticles: Particle[] = Array.from({ length: count }).map((_, i) => ({
       id: i,
       x: Math.random() * 100, // percent
       y: -10, // start above
       color: COLORS[Math.floor(Math.random() * COLORS.length)],
       size: Math.random() * 8 + 4,
       rotation: Math.random() * 360,
       duration: Math.random() * 2 + 2,
       delay: Math.random() * 0.5,
+      drift: Math.random() * 20 - 10,
+      isCircle: Math.random() > 0.5,
     }));
@@
           animate={{
             y: '105vh',
             rotate: p.rotation + 720,
-            x: `${p.x + (Math.random() * 20 - 10)}vw`,
+            x: `${p.x + p.drift}vw`,
             opacity: 0,
           }}
@@
           style={{
             position: 'absolute',
             width: p.size,
             height: p.size,
             backgroundColor: p.color,
-            borderRadius: Math.random() > 0.5 ? '50%' : '0%',
+            borderRadius: p.isCircle ? '50%' : '0%',
           }}

Also applies to: 24-60

🤖 Prompt for AI Agents
In `@web/components/ui/Confetti.tsx` around lines 9 - 18, The Particle interface
and Confetti rendering use Math.random() during render which causes particle
positions and border radii to change on re-renders; modify the Particle type to
include stable precomputed fields (e.g., initialX, initialY, borderRadius,
initialRotation, duration, delay) and generate these random values once when
creating particles (inside a createInitialParticles function, useEffect, or
useRef in the Confetti component) so the render uses particle.initialX /
particle.borderRadius etc. instead of calling Math.random() inline; update any
places referencing x, y, rotation or border radius to use the new stored
properties on Particle (and remove runtime Math.random calls from the JSX) to
prevent jitter.


export const Confetti = ({ count = 50 }: ConfettiProps) => {
const [particles, setParticles] = useState<Particle[]>([]);

useEffect(() => {
const newParticles: Particle[] = Array.from({ length: count }).map((_, i) => ({
id: i,
x: Math.random() * 100, // percent
y: -10, // start above
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: Math.random() * 8 + 4,
rotation: Math.random() * 360,
duration: Math.random() * 2 + 2,
delay: Math.random() * 0.5,
}));
setParticles(newParticles);
}, [count]);

return (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden" aria-hidden="true">
{particles.map((p) => (
<motion.div
key={p.id}
initial={{ y: '-5vh', x: `${p.x}vw`, rotate: p.rotation, opacity: 1 }}
animate={{
y: '105vh',
rotate: p.rotation + 720,
x: `${p.x + (Math.random() * 20 - 10)}vw`,
opacity: 0,
}}
transition={{
duration: p.duration,
delay: p.delay,
ease: 'easeOut',
}}
style={{
position: 'absolute',
width: p.size,
height: p.size,
backgroundColor: p.color,
borderRadius: Math.random() > 0.5 ? '50%' : '0%',
}}
/>
))}
</div>
);
};
9 changes: 2 additions & 7 deletions web/pages/GroupDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ArrowRight, Banknote, Check, Copy, DollarSign, Hash, Layers, LogOut, Pi
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AnalyticsContent } from '../components/AnalyticsContent';
import { SettledUpState } from '../components/SettledUpState';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Modal } from '../components/ui/Modal';
Expand Down Expand Up @@ -847,13 +848,7 @@ export const GroupDetails = () => {
</motion.div>
))}
{!loading && settlements.length === 0 && (
<div className="col-span-full text-center py-20">
<div className={`w-24 h-24 flex items-center justify-center mx-auto mb-6 ${style === THEMES.NEOBRUTALISM ? 'bg-emerald-100 border-2 border-black rounded-none' : 'bg-emerald-100 dark:bg-emerald-900/20 rounded-full'}`}>
<Check size={48} className={style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500'} />
</div>
<h3 className={`text-2xl font-black ${style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-emerald-500'}`}>All Settled Up!</h3>
<p className="opacity-60">No outstanding balances in this group.</p>
</div>
<SettledUpState />
)}
</motion.div>
)}
Expand Down
Loading