rndr realm

Feb 8, 2026

Intro

I posted a dropdown interaction on Twitter a while back and it got way more attention than I expected. A few people asked how I built it, so I figured I'd write about it.

The idea is pretty simple. You have a button, you click it, a dropdown opens. But instead of it just appearing, the dropdown morphs out of the button with this blobby, gooey effect. Like the two shapes are made of the same liquid.

The whole thing is built with React, Motion and an SVG filter. No canvas, no WebGL, nothing wild. I'll walk through how I built it step by step.

The gooey dropdown in action

The basic dropdown

Before any animation or gooey stuff, let's start with the structure. It's a button and a dropdown panel that shows when you click.

Dropdown.tsx

1
const Dropdown = () => {
2
const [isOpen, setIsOpen] = useState(false);
3
4
return (
5
<div className="relative">
6
<button
7
className="size-13 rounded-full bg-[#1a1a1a] flex items-center justify-center"
8
onClick={() => setIsOpen((prev) => !prev)}
9
>
10
<ChatIcon />
11
</button>
12
13
{isOpen && (
14
<div className="absolute top-0 left-1/2 -translate-x-1/2 translate-y-[70px] w-[340px] bg-[#1a1a1a] rounded-3xl overflow-hidden">
15
<div className="py-3 px-5 flex justify-between items-center">
16
<h1 className="text-white text-lg font-bold">Messages</h1>
17
<p className="text-[#8e8e93] text-xs">3 new</p>
18
</div>
19
{/* message list */}
20
</div>
21
)}
22
</div>
23
);
24
};

Nothing fancy here. A useState to track if the dropdown is open, a round button that toggles it, and the dropdown panel positioned right below. The dropdown starts at the same top-0 as the button and gets pushed down with translate-y so they overlap vertically. That overlap is important later when we add the gooey filter because the two shapes need to be close enough to blend into each other.

Animating it

The trick that makes this work is that the dropdown doesn't just fade in. It starts as a small circle, the same size and position as the button, and then expands into the full panel. So it looks like the button itself is stretching open.

To do this I used Motion's AnimatePresence and animated the width, height, borderRadius and y position of the dropdown. The initial state matches the button exactly: 52px circle, sitting right on top of it.

Dropdown.tsx

1
<AnimatePresence>
2
{isOpen && (
3
<motion.div
4
className="absolute top-0 left-1/2 overflow-hidden bg-[#1a1a1a]"
5
initial={{
6
width: 52,
7
height: 52,
8
borderRadius: 26,
9
y: 0,
10
x: "-50%",
11
}}
12
animate={{
13
width: 340,
14
height: 310,
15
borderRadius: 24,
16
y: 70,
17
x: "-50%",
18
}}
19
exit={{
20
width: 52,
21
height: 52,
22
borderRadius: 26,
23
y: 0,
24
x: "-50%",
25
}}
26
transition={{
27
y: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
28
width: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
29
height: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
30
borderRadius: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
31
}}
32
>
33
{/* dropdown content */}
34
</motion.div>
35
)}
36
</AnimatePresence>

A couple things to notice here. The y position moves first, and then the width and height follow with a small delay. That stagger is what gives it that organic feel where it slides down and then opens up, instead of everything happening at once. If you animate all the properties at the same time it feels stiff.

The content inside the dropdown also fades in with its own delay so it doesn't show up while the container is still a tiny circle.

Dropdown.tsx

1
<motion.div
2
initial={{ opacity: 0 }}
3
animate={{ opacity: 1 }}
4
exit={{ opacity: 0 }}
5
transition={{ duration: 0.3, delay: 0.15, ease: "easeOut" }}
6
>
7
{/* messages, header, etc */}
8
</motion.div>

At this point you have a dropdown that morphs open and closed smoothly. It already looks pretty good on its own. But adding the gooey filter is what takes it to the next level.

The gooey effect

This is the part that makes it look like the button and dropdown are melting into each other. It's an SVG filter. I won't go too deep into how SVG filters work (Lucas Bebber has a great article on it), but the short version is: you blur everything, then crank up the alpha contrast so the blurry edges snap back to being sharp. Where two shapes are close enough, their blurred edges merge together before snapping back, and that's what creates the gooey blob effect.

Here's the filter:

gooey-filter.html

1
<svg style="position: absolute; width: 0; height: 0;">
2
<defs>
3
<filter id="gooey">
4
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
5
<feColorMatrix
6
in="blur"
7
mode="matrix"
8
values="1 0 0 0 0
9
0 1 0 0 0
10
0 0 1 0 0
11
0 0 0 20 -10"
12
result="gooey"
13
/>
14
<feBlend in="SourceGraphic" in2="gooey" />
15
</filter>
16
</defs>
17
</svg>

The stdDeviation="10" controls how much blur, and the 20 -10 in the last row of the color matrix controls how aggressively the edges snap back. Higher values mean a sharper cutoff, lower values mean more goo. The feBlend at the end composites the original sharp graphic back on top so the content inside (text, icons) doesn't look blurry.

Then you just apply it to the parent container that wraps both the button and the dropdown:

Dropdown.tsx

1
<div style={{ filter: "url(#gooey)" }}>
2
{/* button */}
3
{/* dropdown */}
4
</div>

That's it. Because the button and dropdown are both inside the same filtered container, the filter sees them as one shape and blends their edges together during the animation.

Putting it all together

Here's the full thing running live. You can toggle the gooey filter on and off to see how much it changes the feel. Try clicking the button with it off first, then turn it on.

import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import "./styles.css";

const messageData = [
  {
    id: 1,
    name: "Alice Johnson",
    message: "Hey! Are we still on for the meeting tomorrow?",
    bg: "linear-gradient(135deg, #818CF8, #C084FC)",
    timeAgo: "2h",
  },
  {
    id: 2,
    name: "Bob Smith",
    message: "Don't forget to check out the new project updates.",
    bg: "linear-gradient(135deg, #F472B6, #FBBF24)",
    timeAgo: "2h",
  },
  {
    id: 3,
    name: "Charlie Davis",
    message: "Can you send me the files from last week?",
    bg: "linear-gradient(135deg, #34D399, #3B82F6)",
    timeAgo: "Yesterday",
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [gooey, setGooey] = useState(true);

  return (
    <>
      <svg style={{ position: "absolute", width: 0, height: 0 }}>
        <defs>
          <filter id="gooey">
            <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -10"
              result="gooey"
            />
            <feBlend in="SourceGraphic" in2="gooey" />
          </filter>
        </defs>
      </svg>

      <div className="container">
        <div
          className="dropdown-wrapper"
          style={gooey ? { filter: "url(#gooey)" } : undefined}
        >
          <motion.div
            className="button-wrapper"
            whileHover={{ scale: 1.03 }}
            whileTap={{ scale: 0.97 }}
          >
            <button
              className="trigger-button"
              onClick={() => setIsOpen((prev) => !prev)}
            >
              <svg
                width="20"
                height="20"
                viewBox="0 0 24 24"
                fill="none"
                stroke="white"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
              </svg>
            </button>
          </motion.div>

          <AnimatePresence>
            {isOpen && (
              <motion.div
                className="dropdown-panel"
                initial={{
                  width: 52,
                  height: 52,
                  borderRadius: 26,
                  y: 0,
                  x: "-50%",
                }}
                animate={{
                  width: 320,
                  height: 290,
                  borderRadius: 24,
                  y: 70,
                  x: "-50%",
                }}
                exit={{
                  width: 52,
                  height: 52,
                  borderRadius: 26,
                  y: 0,
                  x: "-50%",
                  transition: {
                    y: { duration: 0.6, delay: 0.15, ease: [0.22, 1, 0.36, 1] },
                    width: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
                    height: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
                    borderRadius: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
                  },
                }}
                transition={{
                  y: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
                  width: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
                  height: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
                  borderRadius: { duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] },
                }}
              >
                <motion.div
                  className="dropdown-content"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{
                    opacity: 0,
                    transition: { duration: 0.15, ease: "easeIn" },
                  }}
                  transition={{ duration: 0.3, delay: 0.15, ease: "easeOut" }}
                >
                  <div className="dropdown-header">
                    <h1 className="dropdown-title">Messages</h1>
                    <p className="dropdown-count">3 new</p>
                  </div>
                  <div className="dropdown-messages">
                    {messageData.map((msg, i) => (
                      <motion.div
                        key={msg.id}
                        className="message-row"
                        initial={{ opacity: 0, y: 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{
                          duration: 0.25,
                          delay: 0.2 + i * 0.05,
                          ease: [0.4, 0, 0.2, 1],
                        }}
                      >
                        <div className="avatar" style={{ background: msg.bg }} />
                        <div className="message-body">
                          <div className="message-top">
                            <p className="message-name">{msg.name}</p>
                            <p className="message-time">{msg.timeAgo}</p>
                          </div>
                          <p className="message-text">{msg.message}</p>
                        </div>
                      </motion.div>
                    ))}
                  </div>
                  <button className="view-all">View All Messages</button>
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <div className="controls">
          <button
            onClick={() => setGooey((prev) => !prev)}
            className={gooey ? "control-btn active" : "control-btn"}
          >
            Gooey {gooey ? "On" : "Off"}
          </button>
        </div>
      </div>
    </>
  );
}

The gooey filter is doing a lot of heavy lifting for how little code it is. The animation itself is just moving and resizing a div, but the filter makes it feel like something physical is happening. It's one of those things where a small detail completely changes how the interaction feels.

We actually used this same technique on our landing page. Here's what it looks like in a real product context:

The gooey effect on our landing page

You can check it out live at rndrealm.com.

Gooey Dropdown | RNDR Realm