Option wheel
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Hold the plus button and slide toward an option in the ring to pick it.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Camera, FileText, Image as ImageIcon, Link2, Mic, Plus, Video } from "lucide-react";
import { useRef, useState } from "react";
import { Surface } from "../ui";
const OPTIONS = [
{ label: "Photo", Icon: ImageIcon },
{ label: "Camera", Icon: Camera },
{ label: "Video", Icon: Video },
{ label: "Voice", Icon: Mic },
{ label: "Link", Icon: Link2 },
{ label: "Note", Icon: FileText },
];
const STEP = 360 / OPTIONS.length;
export default function OptionWheel() {
const [open, setOpen] = useState(false);
const [hot, setHot] = useState<number | null>(null);
const [picked, setPicked] = useState<string | null>(null);
const center = useRef({ x: 0, y: 0, r: 60 });
const pressed = useRef(false);
const moved = useRef(false);
const openedNow = useRef(false);
const pick = (x: number, y: number) => {
const c = center.current;
const dx = x - c.x;
const dy = y - c.y;
if (Math.hypot(dx, dy) < c.r * 0.45) return null;
const a = ((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360 + STEP / 2) % 360;
return Math.floor(a / STEP);
};
const onDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
const r = e.currentTarget.getBoundingClientRect();
center.current = { x: r.left + r.width / 2, y: r.top + r.height / 2, r: r.width * 1.9 };
pressed.current = true;
moved.current = false;
openedNow.current = !open;
if (!open) setOpen(true);
};
const onMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!pressed.current) return;
const i = pick(e.clientX, e.clientY);
if (i !== null) moved.current = true;
setHot(i);
};
const onUp = () => {
if (!pressed.current) return;
pressed.current = false;
if (hot !== null) {
setPicked(OPTIONS[hot].label);
setOpen(false);
} else if (moved.current) {
setOpen(false);
}
setHot(null);
};
const choose = (i: number) => {
setPicked(OPTIONS[i].label);
setOpen(false);
};
return (
<Surface>
<div className="relative grid h-[13em] w-[13em] place-items-center">
<AnimatePresence>
{open && (
<motion.div
key="ring"
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ type: "spring", stiffness: 380, damping: 28 }}
className="absolute inset-0 rounded-full bg-white/70 shadow-[0_10px_40px_-16px_rgba(0,0,0,0.3),inset_0_0_0_1px_rgba(0,0,0,0.04)] backdrop-blur"
/>
)}
</AnimatePresence>
<AnimatePresence>
{open &&
OPTIONS.map(({ label, Icon }, i) => {
const a = ((i * STEP - 90) * Math.PI) / 180;
const on = hot === i;
return (
<motion.button
key={label}
type="button"
onClick={() => choose(i)}
onPointerEnter={() => !pressed.current && setHot(i)}
onPointerLeave={() => !pressed.current && setHot(null)}
initial={{ x: 0, y: 0, scale: 0.2, opacity: 0 }}
animate={{ x: `${Math.cos(a) * 4.6}em`, y: `${Math.sin(a) * 4.6}em`, scale: on ? 1.22 : 1, opacity: 1 }}
exit={{ x: 0, y: 0, scale: 0.2, opacity: 0, transition: { duration: 0.18 } }}
transition={{ type: "spring", stiffness: 420, damping: 24, delay: i * 0.025 }}
className={`absolute grid h-[2.7em] w-[2.7em] place-items-center rounded-full transition-colors ${on ? "bg-[#151515] text-white" : "bg-white text-[#151515] shadow-[0_2px_8px_rgba(0,0,0,0.08)]"}`}
>
<Icon className="h-[1.15em] w-[1.15em]" strokeWidth={1.8} />
<AnimatePresence>
{on && (
<motion.span
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute top-full mt-[0.35em] whitespace-nowrap rounded-full bg-[#151515] px-[0.6em] py-[0.15em] text-[0.62em] text-white"
>
{label}
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})}
</AnimatePresence>
<motion.button
type="button"
aria-label="Open options"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onClick={() => {
if (!openedNow.current && !moved.current) setOpen(false);
openedNow.current = false;
}}
whileTap={{ scale: 0.92 }}
className="relative z-10 grid h-[3.4em] w-[3.4em] touch-none place-items-center rounded-full bg-[#151515] text-white shadow-[0_8px_18px_-8px_rgba(0,0,0,0.5)]"
>
<motion.span animate={{ rotate: open ? 45 : 0 }} transition={{ type: "spring", stiffness: 400, damping: 22 }}>
<Plus className="h-[1.4em] w-[1.4em]" />
</motion.span>
</motion.button>
</div>
<div className="pointer-events-none absolute bottom-[6%] h-[1.6em]">
<AnimatePresence mode="popLayout">
<motion.p
key={picked ?? "hint"}
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -8, filter: "blur(4px)" }}
className="font-mono text-[0.72em] text-[#8a8984]"
>
{picked ? `added · ${picked.toLowerCase()}` : "hold the button, slide to an option"}
</motion.p>
</AnimatePresence>
</div>
</Surface>
);
}
Prompt
Build "Option wheel", a navigation animation. What it does: Hold the plus button and slide toward an option in the ring to pick it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).