Family Wallets

For a while now, I've been considering sharing the code for the interactions and prototypes I share on X. But there was always something holding me back. I'm not an engineer, and I often worried that my code might be too messy or that the solutions I came up with weren't perfect.


Funny enough, just as I was thinking about it, I stumbled upon this conversation and it encouraged me to share some of the code.

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

As you can see in the preview above, the wallet component has three states: a default state when no wallet is expanded, an expanded state where the wallet changes size and components are added or swapped, and a collapsed state where the wallet is collapsed and components are removed.


To define specific content for each instance, I'm using props. For the animations, I rely on Framer Motion's shared layout animations to transition between different states of the component.


An important thing here is using a unique id for each component instance and setting the layoutId based on that id. This allows Framer Motion to know which components to animate between. If all components shared the same layoutId Framer Motion wouldn't be able to make the connection between them and the animations wouldn't work properly.

The Default State
const WalletCardDefault: FC<WalletCardProps> = ({
  bgColor,
  IconComponent,
  walletName,
  ethValue,
  onClick,
  uniqueId,
}) => {
  return (
    <motion.div
      layoutId={`wallet-${uniqueId}`}
      className="flex flex-col items-start justify-between p-3"
      style={{
        height: "120px",
        width: "160px",
        borderRadius: 24,
        backgroundColor: bgColor,
      }}
    >
      <div className="flex w-full items-start justify-between">
        <motion.div
          layoutId={`icon-${uniqueId}`}
          className="flex items-center justify-center"
        >
          <IconComponent className="icon" />
        </motion.div>
        <motion.div
          initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
          className="toggle"
          onClick={onClick}
        >
          <MoreIcon className="icon" />
        </motion.div>
      </div>
      <div className="container">
        <motion.span layoutId={`walletName-${uniqueId}`}>
          {walletName}
        </motion.span>
        <motion.span layoutId={`ethValue-${uniqueId}`}>{ethValue}</motion.span>
      </div>
    </motion.div>
  );
};
The Expanded State
const WalletCardExpanded: FC<WalletCardProps> = ({
  bgColor,
  IconComponent,
  walletName,
  ethValue,
  onClick,
  uniqueId,
}) => {
  return (
    <motion.div
      layoutId={`wallet-${uniqueId}`}
      className="card-expanded"
      style={{
        height: "200px",
        width: "320px",
        borderRadius: 24,
        backgroundColor: bgColor,
      }}
    >
      <motion.div className="container">
        <motion.div
          layoutId={`icon-${uniqueId}`}
          className="container"
          onClick={onClick}
        >
          <IconComponent className="icon" />
        </motion.div>
        <motion.div
          initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
          className="copy-address"
          whileTap={{ scale: 0.9 }}
        >
          Copy Address
          <motion.div className="button">
            <CopyIcon className="icon" />
          </motion.div>
        </motion.div>
      </motion.div>
      <div className="container">
        <div className="container">
          <motion.span layoutId={`walletName-${uniqueId}`} className="title">
            {walletName}
          </motion.span>
          <motion.span layoutId={`ethValue-${uniqueId}`} className="subtitle">
            {ethValue}
          </motion.span>
        </div>
        <motion.button
          initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
          className="customize-button"
        >
          Customize
        </motion.button>
      </div>
    </motion.div>
  );
};
The Collapsed State
const WalletCardCollapsed: FC<WalletCardProps> = ({
  bgColor,
  IconComponent,
  walletName,
  ethValue,
  onClick,
  uniqueId,
}) => {
  return (
    <motion.div
      layoutId={`wallet-${uniqueId}`}
      onClick={onClick}
      className="card-collapsed"
      style={{
        height: "96px",
        width: "96px",
        borderRadius: 20,
        backgroundColor: bgColor,
      }}
    >
      <div className="container">
        <motion.div layoutId={`icon-${uniqueId}`} className="container">
          <IconComponent className="icon" />
        </motion.div>
      </div>
      <div className="container">
        <motion.span layoutId={`walletName-${uniqueId}`}>
          {walletName}
        </motion.span>
        <motion.span layoutId={`ethValue-${uniqueId}`}>{ethValue}</motion.span>
      </div>
    </motion.div>
  );
};
Assembling the Animation

With the three wallet states defined, we can now assemble the animation. The code tracks which card is currently expanded and allows toggling between expanded and collapsed states when a card is clicked.


I also use the useRef hook to reference the div containing the wallet. This works with the useOnClickOutside hook to detect clicks outside the wallet component, resetting its state.


Lastly, a simple useEffect hook adds an event listener for the Escape key, which resets the component's state when the Escape key is pressed.

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

The final step is to put everything together. While this part of the code could probably be written more efficiently, I wanted to lay out all the different states so I'd have complete control over how everything fits together. This way, it'll be easier to tweak things if needed later on.


I'm wrapping the entire component in a MotionConfig to ensure that the transitions are consistent. Additionally, I'm using AnimatePresence to manage the exit animations for the components inside the wallets.

 return (
    <MotionConfig transition={{ type: "spring", duration: 0.4, bounce: 0.1 }}>
      <div ref={ref}>
        <AnimatePresence mode="popLayout" initial={false}>
          {expandedCardId ? (
            <motion.div
              key="expanded"
              className="container-expanded"
            >
              {expandedCardId === "1" && (
                {/*//   Expanded State 1 Layout*/}
              )}
              {expandedCardId === "2" && (
                {/*//   Expanded State 2 Layout*/}
              )}
              {expandedCardId === "3" && (
                {/*//   Expanded State 3 Layout*/}
              )}
              {expandedCardId === "4" && (
                {/*//   Expanded State 4 Layout*/}
              )}
            </motion.div>
          ) : (
            <motion.div
              key="collapsed"
              className="container-collapsed"
            >
                 {/*//   Collapsed State Layout*/}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </MotionConfig>
  );
};

You can see the full code on CodeSandbox.


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