An expandable action bar that morphs between closed, search, and settings states with an animated width and a shake gesture.
"use client";
import { ArrowLeft, MoreHorizontal, Search, Sun } from "lucide-react";
import { motion, MotionConfig, useAnimate } from "motion/react";
import React, { 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 transition = {
type: "spring",
bounce: 0.1,
duration: 0.2,
};
function Button({
children,
onClick,
disabled,
ariaLabel,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
ariaLabel?: string;
}) {
return (
<button
className="relative flex h-9 w-9 shrink-0 scale-100 select-none appearance-none items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
>
{children}
</button>
);
}
export default function AmieAction() {
const [openState, setOpenState] = useState<"search" | "settings" | "closed">(
"closed",
);
const containerRef = useRef<HTMLDivElement>(null);
const [scope, animate] = useAnimate();
const shake = () => {
if (scope.current && openState === "settings") {
animate(scope.current, { x: [0, 5, -5, 0] }, { duration: 0.2 });
} else {
console.warn("Scope reference is not set!");
}
};
useClickOutside(containerRef, () => {
setOpenState("closed");
});
return (
<MotionConfig transition={transition}>
<div ref={containerRef}>
<div className="h-full w-full rounded-xl border bg-background">
<motion.div
animate={{
width:
openState === "search"
? "340px"
: openState === "settings"
? "auto"
: "98px",
}}
initial={false}
>
<div className="overflow-hidden p-2">
{openState === "closed" && (
<div className="flex space-x-2">
<Button
ariaLabel="User profile"
onClick={() => setOpenState("settings")}
>
<Sun className="h-5 w-5" />
</Button>
<Button
onClick={() => setOpenState("search")}
ariaLabel="Search notes"
>
<Search className="h-5 w-5" />
</Button>
</div>
)}
{openState === "search" && (
<div className="flex space-x-2">
<Button
onClick={() => setOpenState("closed")}
ariaLabel="Back"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="relative w-full">
<input
className="h-9 w-full rounded-lg bg-transparent p-2 text-foreground placeholder:text-muted-foreground outline-none"
autoFocus
placeholder="Search notes"
/>
</div>
</div>
)}
{openState === "settings" && (
<div className="flex space-x-2 items-center">
<Button
onClick={() => setOpenState("closed")}
ariaLabel="Back"
>
<ArrowLeft className="h-5 w-5" />
</Button>
{openState === "settings" && (
<motion.p
ref={scope}
onClick={shake}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-sm text-foreground bg-muted rounded-lg p-2 hover:bg-muted/70 cursor-pointer w-full whitespace-pre"
>
Meeting Zenith Friday at 7
</motion.p>
)}
<Button ariaLabel="Edit">
<MoreHorizontal className="size-5" />
</Button>
</div>
)}
</div>
</motion.div>
</div>
</div>
</MotionConfig>
);
}