Clerk
Blog

Back

Guide


Jan 29, 2024

Back

Guide


Jan 29, 2024

Create Your Own Custom User Menu with Radix - Part 2

Roy Anger

Roy Anger


Extend your Radix powered custom User Menu to turn it into a Sign In or User Profile component


Code samples are from @clerk/nextjs 4.29.5, @radix-ui/react-dropdown-menu 2.0.6, class-variance-authority 0.7.0 and @heroicons/react 2.1.1

Welcome to the second part of building a custom user menu! In the first part, we built a replacement for Clerk’s <UserButton /> using the Radix Dropdown primitive and some of Clerk’s hooks. Now we’ll be upgrading our user button with sign-in functionality when the user is not logged in as well as improve the behavior of the component in several ways.

Refactoring the component

The first step is to refactor the component so it's ready to build upon. Take the contents of the return and create a new component in the file above the exported component. Call the new component <UserButtonAndMenu /> and paste the copied code. We will need to add in the destructures for the user method from the useUser() hook, signOut(), and openUserProfile() from the userClerk() hook, and the router method from the userRouter() hook.

The first step is to refactor the component so it's ready to build upon.

  • Create a new component in the file called <UserButtonAndMenu />
  • Move the JSX from the original <UserButton /> into the <UserButtonAndMenu /> component
  • Move user method from useUser() hook to the new component
  • Move signOut(), and openUserProfile() from userClerk() hook to the new component
  • Move router method from userRouter() hook to the new component\

In the <UserButton /> component, we’re going to leave the check for isLoaded and add a [user.id](http://user.id) check using if ( !user.id ) {} and return the <SignInButton /> component from Clerk if there is no user.

1
"use client";
2
3
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
4
import { useUser, useClerk, SignInButton } from "@clerk/nextjs";
5
import { useRouter } from "next/navigation";
6
import Image from "next/image";
7
import Link from "next/link";
8
9
// Create a new UserButtonandMenu component and move the old return into this
10
const UserButtonAndMenu = () => {
11
const { signOut, openUserProfile } = useClerk();
12
const router = useRouter();
13
const { user } = useUser();
14
15
return (
16
<DropdownMenu.Root>
17
<DropdownMenu.Trigger asChild>
18
{/* Render a button using the image and email from `user` */}
19
<button className="flex flex-row rounded-xl border border-gray-200 bg-white px-4 py-3 text-black drop-shadow-md">
20
<Image
21
alt={user?.primaryEmailAddress?.emailAddress!}
22
src={user?.imageUrl}
23
width={30}
24
height={30}
25
className="mr-2 rounded-full border border-gray-200 drop-shadow-sm"
26
/>
27
{user?.username
28
? user.username
29
: user?.primaryEmailAddress?.emailAddress!}
30
</button>
31
</DropdownMenu.Trigger>
32
<DropdownMenu.Portal>
33
<DropdownMenu.Content className="mt-4 w-52 rounded-xl border border-gray-200 bg-white px-6 py-4 text-black drop-shadow-2xl">
34
<DropdownMenu.Label />
35
<DropdownMenu.Group className="py-3">
36
<DropdownMenu.Item asChild>
37
{/* Create a button with an onClick to open the User Profile modal */}
38
<button onClick={() => openUserProfile()} className="pb-3">
39
Profile
40
</button>
41
</DropdownMenu.Item>
42
<DropdownMenu.Item asChild>
43
{/* Create a fictional link to /subscriptions */}
44
<Link href="/subscriptions" passHref className="py-3">
45
Subscription
46
</Link>
47
</DropdownMenu.Item>
48
</DropdownMenu.Group>
49
<DropdownMenu.Separator className="my-1 h-px bg-gray-500" />
50
<DropdownMenu.Item asChild>
51
{/* Create a Sign Out button -- signOut() takes a call back where the user is redirected */}
52
<button
53
onClick={() => signOut(() => router.push("/"))}
54
className="py-3"
55
>
56
Sign Out{" "}
57
</button>
58
</DropdownMenu.Item>
59
</DropdownMenu.Content>
60
</DropdownMenu.Portal>
61
</DropdownMenu.Root>
62
);
63
};
64
65
// Refactor to show the default <SignInButton /> if the user is logged out
66
// Show the UserButtonAndMenu if the user is logged in
67
export const UserButton = () => {
68
const { isLoaded, user } = useUser();
69
70
if (!isLoaded) return null;
71
72
if (!user?.id) return <SignInButton />
73
74
return <UserButtonAndMenu />;
75
};

Improving the component

If you saved your work and tested it, you will see that the refactoring has already accomplished the base goal — it is now both a Sign-In button and a User Button/User Menu. We can still improve the component and provide a better user experience, so let’s do a few more refactors.

The first step is moving the button to its component. We’ll also add in a forwardRef to plan for the later improvements.

Tip: You might have your own custom button already created or available from a library you are using. If so then you can use that in place of this one.

1
// Add import
2
import * as React from 'react';
3
4
// Create a new <Button /> component using the same classes
5
interface ButtonProps
6
extends React.ButtonHTMLAttributes<HTMLButtonElement> { }
7
8
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
9
({ children, className, ...props }, ref) => {
10
return (
11
<button
12
ref={ref}
13
className={className}
14
{...props}
15
>
16
{children}
17
</button>
18
);
19
},
20
);

The second step is to refactor part of the <UserButtonAndMenu /> component. We want to take advantage of the new <Button />

1
const UserButtonAndMenu = () => {
2
const { user } = useUser();
3
const { signOut, openUserProfile } = useClerk();
4
const router = useRouter();
5
6
return (
7
<DropdownMenu.Root>
8
<DropdownMenu.Trigger asChild>
9
{/* Swap <button /> to the new <Button /> */}
10
<Button>
11
<Image
12
alt={user?.primaryEmailAddress?.emailAddress!}
13
src={user?.imageUrl!}
14
width={30}
15
height={30}
16
className="mr-2 rounded-full border border-gray-200 drop-shadow-sm"
17
/>
18
{user?.username
19
? user.username
20
: user?.primaryEmailAddress?.emailAddress!}
21
</Button>
22
</DropdownMenu.Trigger>
23
24
{/* rest of the component remains the same */}
25
)
26
}

The third step is to refactor the top-level <UserButton /> component to also use the new <Button /> component. This button will use openSignIn() from useClerk() to programmatically open the sign-in modal. This results in a custom button and removal of the Clerk <SignInButton />

1
// Update @clerk/nextjs imports
2
import { useUser, useClerk } from "@clerk/nextjs";
3
4
export const UserButton = () => {
5
const { isLoaded, user } = useUser();
6
// Bring in openSignIn
7
const { openSignIn } = useClerk();
8
9
if (!isLoaded || !user?.id) {
10
/* Use the new <Button /> component for the sign-in button */
11
return <Button onClick={() => openSignIn()}>Sign In</Button>;
12
}
13
14
return <UserButtonAndMenu />;
15
};

You can see that we left the user profile image inside of <UserMenuAndButton /> and passed it to the <Button /> as a child. Depending on your need you could hoist the image handling into the <Button /> — that’s totally up to the needs of what you’re building.

Refining the component

Everything is working nicely at this point, and the structure is in a great place to build on. That said, we can add a few refinements to elevate the user experience. Let’s start by installing an icon package and the class-variance-authority package.

This is using @heroicons/react as it's a simple, one-stop icon package. Use other icons that might suit your design or needs better.

1
pnpm install @heroicons/react class-variance-authority

With that installed, let’s do another refactor of the Button. We’ll use class-variance-authority to expand on what the button can do. This is a very structured approach and provides TypeScript support. We will set up a variant and a size, use the resulting primary and regular for the main button, and menu and small for the buttons in the dropdown menu.

1
// Add imports
2
import { VariantProps, cva } from "class-variance-authority";
3
4
// Configure the styles for the Button and its variants and sizes
5
const button = cva(["flex", "flex-row", "items-center", "rounded-xl"], {
6
variants: {
7
variant: {
8
primary: [
9
"border",
10
"border-gray-200",
11
"bg-white",
12
"text-black",
13
"drop-shadow-md",
14
"hover:bg-stone-100",
15
"hover:text-stone-800",
16
],
17
menu: ["bg-transparent", "text-gray-800/70", "hover:text-gray-900"],
18
},
19
size: {
20
regular: ["px-4", "py-3 "],
21
small: ["py-3", "py-2"],
22
},
23
},
24
defaultVariants: {
25
variant: "primary",
26
size: "regular",
27
},
28
});
29
30
// Extend the default Button types with props created by create-variance-authority
31
interface ButtonProps
32
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
33
VariantProps<typeof button> {}
34
35
// Create a new <Button /> component using the same classes
36
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
37
({ variant, size, children, className, ...props }, ref) => {
38
return (
39
<button
40
ref={ref}
41
className="flex flex-row rounded-xl border border-gray-200 bg-white px-4 py-3 text-black drop-shadow-md"
42
{...props}
43
>
44
{children}
45
</button>
46
);
47
},
48
);
49

This <Button /> component can be used anywhere in your application — if you don’t have a <Button /> you’ve built already or one from a library then you can move this out of the <UserButton /> and instead use it application-wide.

Now that our <Button />has reached its final form, let’s import some icons.

1
// Add import
2
import {
3
ArrowRightCircleIcon,
4
ArrowRightEndOnRectangleIcon,
5
CurrencyDollarIcon,
6
UserIcon,
7
} from "@heroicons/react/24/solid";

Let’s modify the button that serves as the trigger for the User Menu. We’ll use Clerk’s hasImage value from the user return of useUser(). Using them will let us display the UserIcon we just imported when the user hasn’t set a profile image, but use their image when they have. We will also will move the logic we have for the label for the button up.

1
const UserButtonAndMenu = () => {
2
const { signOut, openUserProfile } = useClerk();
3
const router = useRouter();
4
const { user } = useUser();
5
// Use the firstname if there is on, otherwise provide a label */
6
const label = user?.firstName ? user.firstName : "Profile";
7
8
return (
9
<DropdownMenu.Root>
10
<DropdownMenu.Trigger asChild>
11
<Button>
12
{/* Render a button using the image and email from `user` */}
13
{user?.hasImage ? (
14
<Image
15
alt={label}
16
src={user?.imageUrl}
17
width={30}
18
height={30}
19
className="mr-2 rounded-full border border-gray-200 drop-shadow-sm"
20
/>
21
) : (
22
<>
23
{/* Display the icon is there is no profile image */}
24
<UserIcon className="mr-2 h-6 w-auto" />
25
</>
26
)}
27
{label}
28
</Button>
29
</DropdownMenu.Trigger>
30
{/* Rest of the component here */}
31
}

We can use the ArrowRightCircleIcon to add a little flare to the Sign-In button.

1
export const UserButton = () => {
2
const { isLoaded, user } = useUser();
3
const { openSignIn } = useClerk();
4
5
if (!isLoaded) return null;
6
7
if (!user?.id) {
8
return (
9
<Button onClick={() => openSignIn()}>
10
Sign In
11
{/* Add an icon to the Sign-in button */}
12
<ArrowRightCircleIcon className="ml-2 h-6 w-auto" />
13
</Button>
14
);
15
}
16
17
return <UserButtonAndMenu />;

Lastly, we will use UserIcon, ArrowRightEndOnTectangleIcon, and CurrencyDollarIcon to add icons to the drop-down menu. At the same time we will add the variant and size to the buttons so they are using the new configuration.

1
<DropdownMenu.Portal>
2
<DropdownMenu.Content className="mt-4 w-52 rounded-xl border border-gray-200 bg-white px-2 py-2 text-black drop-shadow-2xl">
3
<DropdownMenu.Label />
4
<DropdownMenu.Group className="py-1">
5
<DropdownMenu.Item asChild>
6
<Button
7
onClick={() => openUserProfile()}
8
className="pb-3"
9
variant="menu"
10
size="small"
11
>
12
<UserIcon className="mr-2 h-6 w-auto" />
13
Profile
14
</Button>
15
</DropdownMenu.Item>
16
<DropdownMenu.Item asChild>
17
<Link href="/subscriptions" passHref>
18
<Button className="py-2" variant="menu" size="small">
19
<CurrencyDollarIcon className="mr-2 h-6 w-auto" />
20
Subscription
21
</Button>
22
</Link>
23
</DropdownMenu.Item>
24
</DropdownMenu.Group>
25
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
26
<DropdownMenu.Item asChild>
27
<Button
28
onClick={() => signOut(() => router.push("/"))}
29
className="py-3"
30
variant="menu"
31
size="small"
32
>
33
<ArrowRightEndOnRectangleIcon className="mr-2 h-5 w-auto" /> Sign Out
34
</Button>
35
</DropdownMenu.Item>
36
</DropdownMenu.Content>
37
</DropdownMenu.Portal>
38

Finishing Touches

We can add a few finishing touches to the component to flesh it out some more with a few smaller tweaks and improvements:

  • add an accent variant, and then use that for the user button.
  • give menu variant buttons unique styling.
  • add className="min-w-[192px]" to the sign-in and user button to help give them a more consistent width.
  • add ArrowPathIcon to the icon import create a button for !isLoaded, and give it a loading/spanning state.
  • use the outline-none class to remove the focus ring from the menu items

1
2
"use client";
3
4
import * as React from "react";
5
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
6
import { useUser, useClerk } from "@clerk/nextjs";
7
import { useRouter } from "next/navigation";
8
import Image from "next/image";
9
import Link from "next/link";
10
import {
11
ArrowPathIcon,
12
ArrowRightCircleIcon,
13
ArrowRightEndOnRectangleIcon,
14
CurrencyDollarIcon,
15
UserIcon,
16
} from "@heroicons/react/24/solid";
17
import { VariantProps, cva } from "class-variance-authority";
18
19
const button = cva(["flex", "flex-row", "items-center", "rounded-xl"], {
20
variants: {
21
variant: {
22
primary: [
23
"border",
24
"border-gray-200",
25
"bg-white",
26
"text-black",
27
"drop-shadow-md",
28
"hover:bg-stone-100",
29
"hover:text-stone-800",
30
"justify-center",
31
],
32
accent: [
33
"border",
34
"border-stone-950",
35
"bg-stone-800/70",
36
"hover:bg-stone-950",
37
"text-stone-200",
38
"justify-center",
39
],
40
menu: [
41
"w-full",
42
"justify-start",
43
"bg-transparent",
44
"hover:bg-stone-800/70",
45
"text-gray-800/70",
46
"hover:text-stone-100",
47
"px-4",
48
"rounded-sm",
49
],
50
},
51
size: {
52
regular: ["px-4", "py-3 "],
53
small: ["py-3", "py-2"],
54
},
55
},
56
defaultVariants: {
57
variant: "primary",
58
size: "regular",
59
},
60
});
61
62
interface ButtonProps
63
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
64
VariantProps<typeof button> {}
65
66
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
67
({ variant, size, children, className, ...props }, ref) => {
68
return (
69
<button
70
ref={ref}
71
className={button({ variant, size, className })}
72
{...props}
73
>
74
{children}
75
</button>
76
);
77
},
78
);
79
80
// Create a new UserButtonandMenu component and move the old return into this
81
const UserButtonAndMenu = () => {
82
const { user } = useUser();
83
const { signOut, openUserProfile } = useClerk();
84
const router = useRouter();
85
const label = user?.firstName ? user.firstName : "Profile";
86
87
return (
88
<DropdownMenu.Root>
89
<DropdownMenu.Trigger asChild className="outline-none">
90
<Button variant="accent" className="min-w-[192px]">
91
{user?.hasImage ? (
92
<Image
93
alt={label ? label : "Profile image"}
94
src={user?.imageUrl}
95
width={30}
96
height={30}
97
className="mr-2 rounded-full border border-stone-950 drop-shadow-sm"
98
/>
99
) : (
100
<UserIcon className="mr-2 h-6 w-auto" />
101
)}
102
{label}
103
</Button>
104
</DropdownMenu.Trigger>
105
<DropdownMenu.Portal>
106
<DropdownMenu.Content className="mt-4 w-52 rounded-xl border border-gray-200 bg-white px-2 py-2 text-black drop-shadow-2xl">
107
<DropdownMenu.Label />
108
<DropdownMenu.Group className="py-1">
109
<DropdownMenu.Item asChild className="outline-none">
110
<Button
111
onClick={() => openUserProfile()}
112
className="pb-3"
113
variant="menu"
114
size="small"
115
>
116
<UserIcon className="mr-2 h-6 w-auto" />
117
Profile
118
</Button>
119
</DropdownMenu.Item>
120
<DropdownMenu.Item asChild className="outline-none">
121
<Link href="/subscriptions" passHref>
122
<Button className="py-2" variant="menu" size="small">
123
<CurrencyDollarIcon className="mr-2 h-6 w-auto" />
124
Subscription
125
</Button>
126
</Link>
127
</DropdownMenu.Item>
128
</DropdownMenu.Group>
129
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
130
<DropdownMenu.Item asChild className="outline-none">
131
<Button
132
onClick={() => signOut(() => router.push("/"))}
133
className="py-3"
134
variant="menu"
135
size="small"
136
>
137
<ArrowRightEndOnRectangleIcon className="mr-2 h-5 w-auto" /> Sign Out
138
</Button>
139
</DropdownMenu.Item>
140
</DropdownMenu.Content>
141
</DropdownMenu.Portal>
142
</DropdownMenu.Root>
143
);
144
};
145
146
export const UserButton = () => {
147
const { isLoaded, user } = useUser();
148
const { openSignIn } = useClerk();
149
150
if (!isLoaded)
151
return (
152
<Button onClick={() => openSignIn()} className="w-48">
153
<ArrowPathIcon className="ml-2 h-6 w-auto animate-spin" />
154
</Button>
155
);
156
157
if (!user?.id)
158
return (
159
<Button onClick={() => openSignIn()} className="w-48">
160
Sign In
161
<ArrowRightCircleIcon className="ml-2 h-6 w-auto" />
162
</Button>
163
);
164
165
return <UserButtonAndMenu />;
166
};
167

Example repository: https://github.com/royanger/clerk-custom-user-menu

Explore the Powerful Customization Options Clerk Offers!

With this component you have the building blocks to build out your own user button and menu. Add the new entries to the dropdown that you need for your application, and use the tools provided by Radix and `cva` to design and style your component so it matches your application's design language!

Take a look at our Custom Flows documentation to explore more ways to customize your application using the many hooks and methods Clerks provides. The ability to add the pieces you need from Clerk to fully custom and unique UI provides flexibility to projects.

For more in-depth technical inquiries or to engage with our community, feel free to join our Discord. Stay in the loop with the latest Clerk features, enhancements, and sneak peeks by following our Twitter/X account, @ClerkDev. Your journey to seamless user management starts here!

Clerk's logo

Start now,
no strings attached

Start completely free for up to 10,000 monthly active users and up to 100 monthly active orgs. No credit card required.

Start Building

Pricing built for
businesses of all sizes.

Learn more about our transparent per-user costs to estimate how much your company could save by implementing Clerk.

View pricing
Clerk's logo

Newsletter!

The latest news and updates from Clerk, sent to your inbox.

Clerk logo

Clerk - Complete User Management

TwitterLinkedInGitHubDiscordFacebook

© 2024 Clerk Inc.


product
Components

© 2024 Clerk Inc.