An "Add users to the group" empty state whose stacked result cards animate into a focused search input using shared-layout transitions.
"use client";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { ReactNode, useEffect, useRef, useState } from "react";
function useClickOutside<T extends HTMLElement>(
ref: React.RefObject<T | null>,
handler: (event: MouseEvent | TouchEvent) => void,
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref.current;
if (!el || el.contains(event.target as Node)) return;
handler(event);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref, handler]);
}
const searchVariants: Variants = {
initial: {
y: 10,
opacity: 0,
scale: 0.6,
},
animate: {
y: 0,
opacity: 1,
scale: 1,
},
exit: {
y: 10,
opacity: 0,
scale: 0.6,
},
};
const addVariants: Variants = {
initial: {
y: -20,
opacity: 0,
},
animate: {
y: 0,
opacity: 1,
},
exit: {
y: -20,
opacity: 0,
},
};
const SearchUser = () => {
const [active, setActive] = useState(false);
const [_, setValue] = useState("");
const searchContainer = useRef<HTMLDivElement>(null);
useClickOutside(searchContainer, () => {
setActive(false);
});
return (
<div className="size-full flex w-full flex-col bg-background rounded-xl">
<AnimatePresence mode="wait">
{active ? (
<motion.div
ref={searchContainer}
key="search-container"
initial="initial"
animate="animate"
exit="exit"
variants={searchVariants}
className="mx-auto max-w-md w-full h-12 bg-gray-200 mt-10 border-4 border-gray-400/30 px-4 flex items-center gap-2 rounded-xl relative"
>
<Search className="size-4 text-gray-400/50" />
<input
type="text"
placeholder="Search user"
autoFocus
className="bg-transparent border-none outline-none text-gray-400"
onChange={(e) => setValue(e.target.value)}
/>
</motion.div>
) : (
<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={addVariants}
className="size-full flex items-center justify-center flex-col"
>
<div className="relative flex items-center justify-center">
{Array.from({ length: 3 }).map((_, index) => (
<motion.div
layoutId={`searchUser-item-${index}`}
key={index}
className="border-4 absolute bg-background overflow-clip rounded-3xl flex items-center justify-center group"
transition={{
duration: 0.2,
ease: "linear",
}}
style={{
width: 300 + index * 50,
height: 80,
top: index * 20,
}}
>
<div className="size-full flex items-center justify-between bg-gray-200 p-5">
<motion.div
layoutId={`searchUser-icon-${index}`}
className="size-10 bg-background min-w-10 rounded-full"
></motion.div>
<motion.div
layoutId={`searchUser-indicator-second-item-${index}`}
className="h-4 rounded-full bg-muted w-1/2"
/>
</div>
</motion.div>
))}
</div>
<motion.div
layout
className="max-w-xl mx-auto mt-36 cursor-pointer rounded-3xl flex gap-4 items-flex items-center justify-center flex-col text-center"
>
<h1 className="font-semibold text-2xl">Add users to the group</h1>
<div className="text-xl text-muted-foreground font-medium">
Start by adding uses to the group or skip this step and do it
later.
</div>
<div className="flex flex-col gap-2 w-full">
<Button
onClick={() => setActive((prev) => !prev)}
className="text-primary-foreground"
>
Add User
</Button>
<Button className="border-2 bg-secondary text-secondary-foreground">
Skip
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
interface ButtonProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
const Button = ({ children, className, onClick }: ButtonProps) => {
return (
<button
className={cn(
"bg-primary w-full text-white py-2 rounded-xl outline-none",
className,
)}
onClick={onClick}
>
{children}
</button>
);
};
export default SearchUser;