Family Wallets

Recreating the Family wallet interaction on the web.

Jakub1.03 ETH
Savings25.08 ETH
Rainy Day0.04 ETH
Spending0 ETH
See the full code on CodeSandbox.
Structure

The wallet component is made up of three different states: Default, Expanded and Collapsed.


The key aspect of this animation is assigning a unique layoutId to each component that needs to transition between states. In this animation, these components include the card base, icon, wallet name, and ETH value. This approach helps Motion identify which elements should transition together.

If all components shared the same layoutId Motion wouldn't be able to distinguish them, resulting in incorrect components being animated.

<motion.span layoutId={`ethValue-${uniqueId}`} className="title">
  {ethValue}
</motion.span>
Assembling the Animation

With the wallet states defined, we assemble the animation by tracking which card is currently expanded and toggling between expanded and collapsed states upon clicking a card.


The useOnClickOutside hook resets the expanded state when the user clicks outside the wallet.


Additionally, an event listener for the Escape key resets the component state when pressed.

const FamilyWallets = () => {
  const [expandedCardId, setExpandedCardId] = useState<string | null>(null);
  const ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") setExpandedCardId(null);
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);
 
  useOnClickOutside(ref as React.RefObject<HTMLElement>, () => {
    if (expandedCardId) setExpandedCardId(null);
  });
};

The final step is assembling everything together. The wallets are mapped over, each receiving a unique ID alongside other necessary props.


The entire component is wrapped in a MotionConfig to ensure consistent transitions across all elements.

<MotionConfig transition={{ type: "spring", duration: 0.4, bounce: 0.1 }}>
  <div ref={ref}>
    <AnimatePresence mode="popLayout" initial={false}>
      {expandedCardId ? (
        <motion.div
          key="expanded"
          className="flex flex-col items-center justify-center gap-4"
        >
          <div style={{ height: 200 }} className="flex gap-4">
            {WALLET_DATA.map((wallet) =>
              wallet.id === expandedCardId ? (
                <WalletCard
                  key={wallet.id}
                  wallet={wallet}
                  onClick={() => setExpandedCardId(null)}
                  variant="expanded"
                />
              ) : null
            )}
          </div>
          <div className="flex gap-4" style={{ width: 320, height: 96 }}>
            {WALLET_DATA.map((wallet) =>
              wallet.id !== expandedCardId ? (
                <WalletCard
                  key={wallet.id}
                  wallet={wallet}
                  onClick={() => setExpandedCardId(wallet.id)}
                  variant="collapsed"
                />
              ) : null
            )}
          </div>
        </motion.div>
      ) : (
        <motion.div
          key="collapsed"
          className="flex flex-col items-center justify-center gap-4"
        >
          <div className="flex gap-4">
            {WALLET_DATA.slice(0, 2).map((wallet) => (
              <WalletCard
                key={wallet.id}
                wallet={wallet}
                onClick={() => setExpandedCardId(wallet.id)}
                variant="default"
              />
            ))}
          </div>
          <div className="flex gap-4">
            {WALLET_DATA.slice(2).map((wallet) => (
              <WalletCard
                key={wallet.id}
                wallet={wallet}
                onClick={() => setExpandedCardId(wallet.id)}
                variant="default"
              />
            ))}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  </div>
</MotionConfig>
More

The interaction was inspired by Family. In case you have any questions you can reach me at jakub@kbo.sk or X (Twitter).