{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","skeuomorphism"],"dependencies":["motion"],"description":"A temperature knob for generation — turn it and the dial itself heats up: the LED ring fills from cool blue to ember, the glow swells, and the readout drifts from precise to unhinged.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { VibeDial } from \"./vibe-dial\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_50%_42%,rgb(234_88_12/0.12)_0%,transparent_40%),radial-gradient(ellipse_at_50%_45%,#2a150b_0%,#170b06_55%,#0a0503_100%)] p-5\">\n    <VibeDial />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/vibe-dial/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\n\n/**\n * Vibe dial — a temperature knob for generation. Turn it and the dial itself\n * heats up: the LED ring fills and shifts from cool blue to ember, the glow\n * swells, and the readout drifts from \"precise\" to \"unhinged\". Temperature as\n * something you can feel, not a float in a settings panel.\n */\n\nconst MIN = 0;\nconst MAX = 2;\nconst SWEEP = 270;\nconst TICKS = 41;\n\nconst MOODS: { at: number; label: string }[] = [\n  { at: 0, label: \"Precise\" },\n  { at: 0.4, label: \"Balanced\" },\n  { at: 0.8, label: \"Creative\" },\n  { at: 1.2, label: \"Loose\" },\n  { at: 1.6, label: \"Unhinged\" },\n];\n\nconst moodFor = (t: number) => MOODS.findLast((mood) => t >= mood.at)?.label ?? \"Precise\";\nconst clampTemp = (value: number) => Math.min(MAX, Math.max(MIN, value));\n\nconst ARC = 2 * Math.PI * 40;\nconst tickAngle = (index: number) => -135 + (index / (TICKS - 1)) * SWEEP;\n\nexport const VibeDial = () => {\n  const [temp, setTemp] = useState(0.2);\n  const [dragging, setDragging] = useState(false);\n  const dialRef = useRef<HTMLDivElement | null>(null);\n\n  // React registers wheel listeners as passive, so preventDefault there is ignored; the dial\n  // needs a native non-passive listener to keep the wheel from scrolling the page.\n  useEffect(() => {\n    const dial = dialRef.current;\n    if (!dial) {\n      return;\n    }\n    const onWheel = (event: WheelEvent) => {\n      event.preventDefault();\n      setTemp((current) => clampTemp(current - event.deltaY * 0.0035));\n    };\n    dial.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => dial.removeEventListener(\"wheel\", onWheel);\n  }, []);\n\n  const fraction = temp / MAX;\n  const angle = -135 + fraction * SWEEP;\n  const hue = 215 + temp * 85;\n  const wild = Math.max(0, temp - 1.2) / 0.8;\n  const mood = moodFor(temp);\n  const lit = Math.round(fraction * (TICKS - 1));\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"flex flex-col items-center select-none\">\n        <div\n          ref={dialRef}\n          className=\"relative size-[300px] cursor-ns-resize touch-none rounded-full\"\n          onPointerDown={(event) => {\n            event.currentTarget.setPointerCapture(event.pointerId);\n            setDragging(true);\n          }}\n          onPointerMove={(event) => {\n            if (!event.currentTarget.hasPointerCapture(event.pointerId)) {\n              return;\n            }\n            setTemp((current) => clampTemp(current - event.movementY * 0.009));\n          }}\n          onPointerUp={(event) => {\n            event.currentTarget.releasePointerCapture(event.pointerId);\n            setDragging(false);\n          }}\n        >\n          <motion.div\n            aria-hidden\n            animate={{ opacity: 0.18 + fraction * 0.5, scale: 0.85 + fraction * 0.3 }}\n            transition={{ damping: 30, stiffness: 200, type: \"spring\" }}\n            className=\"pointer-events-none absolute -inset-16 rounded-full blur-2xl\"\n            style={{\n              background: `radial-gradient(circle, oklch(0.62 0.24 ${String(hue)} / 0.55) 0%, transparent 65%)`,\n            }}\n          />\n\n          <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full\">\n            {Array.from({ length: TICKS }, (_, index) => {\n              const major = index % 10 === 0;\n              return (\n                <line\n                  key={index}\n                  x1=\"50\"\n                  y1={major ? 1.5 : 2.5}\n                  x2=\"50\"\n                  y2=\"6\"\n                  stroke={\n                    index <= lit\n                      ? `oklch(0.8 0.19 ${String(215 + (index / (TICKS - 1)) * 170)})`\n                      : \"rgba(255,255,255,0.12)\"\n                  }\n                  strokeWidth={major ? 1.1 : 0.7}\n                  strokeLinecap=\"round\"\n                  transform={`rotate(${String(tickAngle(index))} 50 50)`}\n                />\n              );\n            })}\n            <circle\n              cx=\"50\"\n              cy=\"50\"\n              r=\"40\"\n              fill=\"none\"\n              stroke=\"rgba(255,255,255,0.07)\"\n              strokeWidth=\"2.5\"\n              strokeDasharray={`${String((SWEEP / 360) * ARC)} ${String(ARC)}`}\n              strokeLinecap=\"round\"\n              transform=\"rotate(135 50 50)\"\n            />\n            <motion.circle\n              cx=\"50\"\n              cy=\"50\"\n              r=\"40\"\n              fill=\"none\"\n              stroke={`oklch(0.74 0.2 ${String(hue)})`}\n              strokeWidth=\"2.5\"\n              animate={{\n                strokeDasharray: `${String(fraction * (SWEEP / 360) * ARC)} ${String(ARC)}`,\n              }}\n              transition={{ duration: 0.12 }}\n              strokeLinecap=\"round\"\n              transform=\"rotate(135 50 50)\"\n            />\n          </svg>\n\n          <motion.div\n            animate={{ rotate: angle, scale: dragging ? 1.03 : 1 }}\n            transition={{ damping: 30, stiffness: 400, type: \"spring\" }}\n            className=\"absolute inset-[44px] rounded-full bg-gradient-to-b from-[#35291f] to-[#140d09] shadow-[0_18px_40px_rgb(0_0_0/0.6),inset_0_1px_0_rgb(255_255_255/0.08)] ring-1 ring-white/10\"\n          >\n            <span\n              className=\"absolute top-[14px] left-1/2 h-[22px] w-[5px] -translate-x-1/2 rounded-full\"\n              style={{\n                background: `oklch(0.82 0.2 ${String(hue)})`,\n                boxShadow: `0 0 12px oklch(0.75 0.22 ${String(hue)})`,\n              }}\n            />\n          </motion.div>\n\n          <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center\">\n            <span\n              className=\"text-[44px] leading-none font-bold tracking-tight tabular-nums\"\n              style={{\n                color: `oklch(${String(0.97 - fraction * 0.1)} ${String(fraction * 0.12)} ${String(hue)})`,\n              }}\n            >\n              {temp.toFixed(2)}\n            </span>\n            <span className=\"mt-1.5 text-[10px] tracking-[0.22em] text-white/35 uppercase\">\n              temperature\n            </span>\n          </div>\n        </div>\n\n        <div className=\"relative mt-6 h-8 w-[260px]\">\n          <AnimatePresence initial={false}>\n            <motion.p\n              key={mood}\n              initial={{ filter: \"blur(4px)\", opacity: 0, y: 8 }}\n              animate={{ filter: \"blur(0px)\", opacity: 1, rotate: wild * -3, y: 0 }}\n              exit={{ filter: \"blur(4px)\", opacity: 0, y: -8 }}\n              transition={{ duration: 0.22 }}\n              className=\"absolute inset-0 text-center text-[24px] font-bold tracking-tight\"\n              style={{\n                color: `oklch(0.8 0.17 ${String(hue)})`,\n                fontStyle: wild > 0.5 ? \"italic\" : \"normal\",\n              }}\n            >\n              {mood}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n\n        <div className=\"mt-3 flex w-[220px] justify-between text-[10px] tracking-[0.18em] text-white/30 uppercase\">\n          <span>safe</span>\n          <span>spicy</span>\n        </div>\n      </div>\n    </MotionConfig>\n  );\n};\n","path":"/vibe-dial.tsx","target":"uicapsule/vibe-dial/vibe-dial.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/vibe-dial","name":"vibe-dial","registryDependencies":[],"title":"Vibe Dial","type":"registry:block"}