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
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryMicro Interaction
ReactMotionTailwind CSSSolar Icons
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryMicro Interaction
ReactMotionTailwind CSSSolar Icons