Skip to main content

Unfurl Menu

An action menu that unfurls out of a single round trigger: the container itself layout-morphs from a 36px circle into a rounded pill growing up, down, left, or right while items pop in with a scale stagger. Clicking outside or picking an action folds it back into the circle, and an optional labels mode widens rows into icon + text.

Micro InteractionReactMotionTailwind CSSSolar Icons
CSSTailwind

Manual

Create a file and paste the following code into it.

unfurl-menu.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"use client";

import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MenuDots } from "@solar-icons/react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  UnfurlMenu                                                         */
/* ------------------------------------------------------------------ */

export interface UnfurlMenuItem {
  icon: ReactNode;
  label: string;
  onClick?: () => void;
}

interface UnfurlMenuProps {
  items: UnfurlMenuItem[];
  /** Which way the pill unfurls from the trigger [Optional, default: "down"] */
  direction?: "up" | "down" | "left" | "right";
  /** Show text labels next to icons [Optional, default: false] */
  showLabels?: boolean;
  triggerIcon?: ReactNode;
  className?: string;
}

/* Anchor the morphing pill so the trigger never moves while items unfurl
   away from it; "up"/"left" render the trigger last to hold the corner. */
const ANCHOR: Record<string, CSSProperties> = {
  down: { top: 0, left: 0 },
  right: { top: 0, left: 0 },
  up: { bottom: 0, left: 0 },
  left: { top: 0, right: 0 },
};

const FLEX_DIR: Record<string, CSSProperties["flexDirection"]> = {
  down: "column",
  up: "column",
  right: "row",
  left: "row",
};

const triggerLast = (d: string) => d === "up" || d === "left";

/**
 * An action menu that unfurls out of a single round trigger: the container
 * itself layout-morphs from a 36px circle into a rounded pill growing in the
 * chosen direction while items pop in with a scale stagger. Clicking outside
 * or picking an item folds it back into the circle.
 * @param {UnfurlMenuItem[]} items - Actions with icon + label [Required]
 * @param {"up"|"down"|"left"|"right"} direction - Growth direction [Optional, default: "down"]
 * @param {boolean} showLabels - Render labels next to icons [Optional, default: false]
 */
export function UnfurlMenu({
  items,
  direction = "down",
  showLabels = false,
  triggerIcon,
  className,
}: UnfurlMenuProps) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  const trigger = (
    <motion.button
      layout
      type="button"
      onClick={() => setOpen((v) => !v)}
      aria-expanded={open}
      aria-label={open ? "Close menu" : "Open menu"}
      style={{ borderRadius: 9999, flexShrink: 0 }}
      className="flex size-9 cursor-pointer items-center justify-center text-zinc-700 transition-colors hover:bg-zinc-100 active:scale-[0.96] dark:text-zinc-200 dark:hover:bg-zinc-800"
    >
      <motion.span
        animate={{ rotate: open ? 90 : 0 }}
        transition={{ type: "spring", stiffness: 400, damping: 28 }}
        className="flex items-center justify-center"
      >
        {triggerIcon ?? <MenuDots size={18} weight="Bold" />}
      </motion.span>
    </motion.button>
  );

  return (
    <div ref={rootRef} className={cn("relative z-50 size-9", className)}>
      <motion.div
        layout
        style={{
          position: "absolute",
          ...ANCHOR[direction],
          flexDirection: FLEX_DIR[direction],
          borderRadius: open ? 20 : 9999,
          padding: open ? 6 : 0,
          gap: open ? 4 : 0,
          display: "inline-flex",
          alignItems: showLabels ? "stretch" : "center",
        }}
        className="overflow-hidden border border-zinc-200 bg-white shadow-lg shadow-zinc-900/5 dark:border-zinc-700 dark:bg-zinc-900"
        transition={{ type: "spring", stiffness: 380, damping: 30 }}
      >
        {triggerLast(direction) && (
          <MenuItems items={items} open={open} showLabels={showLabels} onPick={() => setOpen(false)} />
        )}
        {trigger}
        {!triggerLast(direction) && (
          <MenuItems items={items} open={open} showLabels={showLabels} onPick={() => setOpen(false)} />
        )}
      </motion.div>
    </div>
  );
}

function MenuItems({
  items,
  open,
  showLabels,
  onPick,
}: {
  items: UnfurlMenuItem[];
  open: boolean;
  showLabels: boolean;
  onPick: () => void;
}) {
  return (
    <AnimatePresence>
      {open &&
        items.map((item, index) => (
          <motion.button
            layout
            key={item.label}
            type="button"
            initial={{ opacity: 0, scale: 0.7 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.7 }}
            transition={{
              type: "spring",
              stiffness: 380,
              damping: 26,
              delay: 0.03 * index,
            }}
            onClick={() => {
              item.onClick?.();
              onPick();
            }}
            className={cn(
              "flex cursor-pointer items-center gap-2.5 rounded-full text-zinc-700 transition-colors hover:bg-zinc-100 active:scale-[0.97] dark:text-zinc-200 dark:hover:bg-zinc-800",
              showLabels ? "justify-start px-3 py-2" : "size-9 justify-center",
            )}
          >
            <span className="flex-shrink-0">{item.icon}</span>
            {showLabels && (
              <span className="whitespace-nowrap text-left text-sm font-medium">
                {item.label}
              </span>
            )}
          </motion.button>
        ))}
    </AnimatePresence>
  );
}

Update the import paths to match your project setup.

Similar components

Tilt Pointer

Halftone Portrait

Edge Veil Blur

Spotlight Follow

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryMicro Interaction
ReactMotionTailwind CSSSolar Icons