Baktiyar Ablaikhan

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).