Sidebar User Menu component
Install
npx shadcn add https://ui.happenings.social/r/sidebar-user-menu.json
Or with namespace
npx shadcn add @happenings/sidebar-user-menu
Preview
Registry block: sidebar footer with user avatar, name, and dropdown menu.
Dependencies
Registry dependencies
src/components/sidebar-user-menu.tsx
"use client";
import { type ReactNode, useState } from "react";
import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleHalfStroke,
faDroplet,
faEllipsis,
faGlobe,
faMoon,
faMountain,
faRightFromBracket,
faSun,
faSunBright,
} from "@fortawesome/pro-solid-svg-icons";
import { cn } from "../lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
export interface SidebarUserMenuLocale {
value: string;
label: string;
}
export interface SidebarUserMenuLabels {
theme: string;
themeLight: string;
themeWarm: string;
themeStone: string;
themeDark: string;
themeSlate: string;
themeAuto: string;
language: string;
signOut: string;
signOutTitle: string;
signOutDescription: string;
signOutCancel: string;
}
export interface SidebarUserMenuProps {
user?: {
displayName: string;
email?: string | undefined;
} | undefined;
avatar?: ReactNode | undefined;
children?: ReactNode | undefined;
locales: readonly SidebarUserMenuLocale[];
currentLocale: string;
onLocaleChange: (locale: string) => void;
onSignOut: () => void | Promise<void>;
labels: SidebarUserMenuLabels;
triggerClassName?: string | undefined;
}
export function SidebarUserMenu({
user,
avatar,
children,
locales,
currentLocale,
onLocaleChange,
onSignOut,
labels,
triggerClassName,
}: SidebarUserMenuProps) {
const { theme, setTheme } = useTheme();
const [signOutOpen, setSignOutOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
async function handleSignOut() {
setSigningOut(true);
try {
await onSignOut();
} catch {
setSigningOut(false);
}
}
const themeOptions = [
{ value: "light", icon: faSun, label: labels.themeLight },
{ value: "warm", icon: faSunBright, label: labels.themeWarm },
{ value: "stone", icon: faMountain, label: labels.themeStone },
{ value: "dark", icon: faMoon, label: labels.themeDark },
{ value: "slate", icon: faDroplet, label: labels.themeSlate },
{ value: "system", icon: faCircleHalfStroke, label: labels.themeAuto },
] as const;
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-xl p-3 text-left transition-[background-color,transform] duration-150 hover:bg-zinc-100 active:scale-[0.98] dark:hover:bg-zinc-800/60",
triggerClassName,
)}
>
{avatar}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{user?.displayName ?? "User"}
</p>
{user?.email && (
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
{user.email}
</p>
)}
</div>
<FontAwesomeIcon
icon={faEllipsis}
className="size-5 shrink-0 text-zinc-400"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={8}
className="w-56 rounded-xl border-zinc-200 bg-white p-1 dark:border-zinc-800 dark:bg-zinc-950"
>
{children}
{children && <DropdownMenuSeparator className="my-1" />}
{/* Theme sub-menu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer rounded-lg transition-all active:scale-95">
<div className="flex items-center gap-3 py-1">
<FontAwesomeIcon
icon={faSun}
className="size-4 text-zinc-500"
/>
<span>{labels.theme}</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
className="w-44 rounded-xl border-zinc-200 bg-white p-1 dark:border-zinc-800 dark:bg-zinc-950"
suppressHydrationWarning
>
{themeOptions.map((opt) => (
<DropdownMenuItem
key={opt.value}
onSelect={() => setTheme(opt.value)}
className={cn(
"cursor-pointer rounded-lg transition-all active:scale-95",
theme === opt.value && "bg-zinc-100 dark:bg-zinc-800",
)}
>
<div className="flex items-center gap-3 py-0.5">
<FontAwesomeIcon icon={opt.icon} className="size-3.5 text-zinc-500" />
<span>{opt.label}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Language sub-menu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer rounded-lg transition-all active:scale-95">
<div className="flex items-center gap-3 py-1">
<FontAwesomeIcon
icon={faGlobe}
className="size-4 text-zinc-500"
/>
<span>{labels.language}</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="w-44 rounded-xl border-zinc-200 bg-white p-1 dark:border-zinc-800 dark:bg-zinc-950">
{locales.map((loc) => (
<DropdownMenuItem
key={loc.value}
onSelect={() => onLocaleChange(loc.value)}
className={cn(
"cursor-pointer rounded-lg transition-all active:scale-95",
currentLocale === loc.value && "bg-zinc-100 dark:bg-zinc-800",
)}
>
<div className="flex items-center gap-3 py-0.5">
<span className="w-5 text-center text-xs font-medium uppercase tracking-wide text-zinc-500">
{loc.value}
</span>
<span>{loc.label}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onSelect={() => setSignOutOpen(true)}
className="cursor-pointer rounded-lg text-red-600 transition-all focus:text-red-600 active:scale-95 dark:text-red-400 dark:focus:text-red-400"
>
<div className="flex items-center gap-3 py-1">
<FontAwesomeIcon
icon={faRightFromBracket}
className="size-4"
/>
<span>{labels.signOut}</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={signOutOpen} onOpenChange={setSignOutOpen}>
<AlertDialogContent className="max-w-sm rounded-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="text-xl font-semibold">
{labels.signOutTitle}
</AlertDialogTitle>
<AlertDialogDescription className="text-base text-muted-foreground">
{labels.signOutDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={signingOut}
className="rounded-full px-6"
>
{labels.signOutCancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => void handleSignOut()}
disabled={signingOut}
className="rounded-full bg-zinc-900 px-6 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
{signingOut ? `${labels.signOut}…` : labels.signOut}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}