{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","skeuomorphism"],"dependencies":["@number-flow/react","lucide-react"],"description":"A replica of Tesla's climate screen — drag the airstream to aim the vents, with live temperature, fan, and seat-heat response.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { TeslaClimate } from \"./tesla-climate\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh flex-col items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_50%_25%,#26272b_0%,#141518_50%,#08090b_100%)] px-5\">\n    <TeslaClimate />\n    <p className=\"mt-6 text-xs text-white/25\">Drag anywhere to aim the air</p>\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/tesla-climate/preview.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useState } from \"react\";\nimport NumberFlow from \"@number-flow/react\";\nimport { ChevronLeft, ChevronRight, Fan, Power } from \"lucide-react\";\n\nimport { VentScene } from \"./vent-scene\";\n\nconst MIN_TEMP = 60;\nconst MAX_TEMP = 80;\n\nconst tempLabel = (tempF: number) => {\n  if (tempF <= MIN_TEMP) {\n    return \"LO\";\n  }\n  if (tempF >= MAX_TEMP) {\n    return \"HI\";\n  }\n  return String(tempF);\n};\n\nexport const TeslaClimate = () => {\n  const [powerOn, setPowerOn] = useState(true);\n  const [acOn, setAcOn] = useState(true);\n  const [tempF, setTempF] = useState(68);\n  const [fanSpeed, setFanSpeed] = useState(4);\n\n  const adjustTemp = (delta: number) => {\n    setTempF((previous) => Math.min(MAX_TEMP, Math.max(MIN_TEMP, previous + delta)));\n  };\n\n  const adjustFan = (delta: number) => {\n    setFanSpeed((previous) => Math.min(10, Math.max(1, previous + delta)));\n  };\n\n  const label = tempLabel(tempF);\n\n  return (\n    <div className=\"w-[min(620px,94vw)] overflow-hidden rounded-[24px] shadow-2xl shadow-black/50 ring-1 ring-black/15 select-none\">\n      <div className=\"relative h-[380px]\">\n        <VentScene tempF={tempF} fanSpeed={fanSpeed} powerOn={powerOn} acOn={acOn} />\n\n        <div className=\"absolute inset-x-0 bottom-4 flex items-center justify-center gap-2 px-4\">\n          <button\n            type=\"button\"\n            aria-pressed={powerOn}\n            aria-label={powerOn ? \"Turn climate off\" : \"Turn climate on\"}\n            onClick={() => setPowerOn((previous) => !previous)}\n            className={`grid h-10 w-11 place-items-center rounded-lg shadow-sm ring-1 transition-colors ${\n              powerOn\n                ? \"bg-[#3d63f5] text-white ring-[#3d63f5]\"\n                : \"bg-white/70 text-[#5b6472] ring-black/5 hover:bg-white/90\"\n            }`}\n          >\n            <Power className=\"size-4\" />\n          </button>\n\n          <button\n            type=\"button\"\n            aria-pressed={acOn}\n            onClick={() => setAcOn((previous) => !previous)}\n            className={`h-10 rounded-lg px-3.5 text-[13px] font-medium shadow-sm ring-1 transition-colors disabled:opacity-60 ${\n              acOn && powerOn\n                ? \"bg-[#96a3f4]/50 text-[#2c3ea8] ring-[#8291ea]/40\"\n                : \"bg-white/70 text-[#5b6472] ring-black/5 hover:bg-white/90\"\n            }`}\n            disabled={!powerOn}\n          >\n            A/C\n          </button>\n\n          <div className=\"flex h-10 items-center rounded-lg bg-white/70 shadow-sm ring-1 ring-black/5\">\n            <button\n              type=\"button\"\n              aria-label=\"Decrease temperature\"\n              onClick={() => adjustTemp(-1)}\n              className=\"grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50\"\n              disabled={!powerOn}\n            >\n              <ChevronLeft className=\"size-4\" />\n            </button>\n            <div className=\"w-8 text-center text-[15px] font-semibold text-[#3c4452] tabular-nums\">\n              {label === \"LO\" || label === \"HI\" ? (\n                <span>{label}</span>\n              ) : (\n                <NumberFlow value={tempF} />\n              )}\n            </div>\n            <button\n              type=\"button\"\n              aria-label=\"Increase temperature\"\n              onClick={() => adjustTemp(1)}\n              className=\"grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50\"\n              disabled={!powerOn}\n            >\n              <ChevronRight className=\"size-4\" />\n            </button>\n          </div>\n\n          <div className=\"flex h-10 items-center rounded-lg bg-white/70 shadow-sm ring-1 ring-black/5\">\n            <button\n              type=\"button\"\n              aria-label=\"Decrease fan speed\"\n              onClick={() => adjustFan(-1)}\n              className=\"grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50\"\n              disabled={!powerOn}\n            >\n              <ChevronLeft className=\"size-4\" />\n            </button>\n            <span className=\"flex items-center gap-1.5 px-1 text-[13px] font-medium text-[#5b6472]\">\n              <Fan\n                className={`size-4 ${powerOn ? \"animate-spin\" : \"\"}`}\n                style={\n                  powerOn\n                    ? { animationDuration: `${(4.4 - fanSpeed * 0.36).toFixed(2)}s` }\n                    : undefined\n                }\n              />\n              <span className=\"w-4 text-center tabular-nums\">\n                <NumberFlow value={fanSpeed} />\n              </span>\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Increase fan speed\"\n              onClick={() => adjustFan(1)}\n              className=\"grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50\"\n              disabled={!powerOn}\n            >\n              <ChevronRight className=\"size-4\" />\n            </button>\n          </div>\n        </div>\n\n        {!powerOn && (\n          <p className=\"pointer-events-none absolute inset-x-0 top-[46%] text-center text-xs tracking-widest text-[#8a93a1] uppercase\">\n            Climate off\n          </p>\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/tesla-climate.tsx","target":"uicapsule/tesla-climate/tesla-climate.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { FC } from \"react\";\n\ninterface VentSceneProps {\n  tempF: number;\n  fanSpeed: number;\n  powerOn: boolean;\n  acOn: boolean;\n}\n\nconst STRANDS = 38;\nconst SEGMENTS = 16;\nconst SLIT_Y = 0.5;\nconst SLIT_WIDTH = 0.46;\n\n// Mirrors the MIN_TEMP/MAX_TEMP range in tesla-climate.tsx; duplicated rather than\n// imported so the tint math stays independent of the control panel.\nconst TINT_MIN_TEMP = 60;\nconst TINT_TEMP_SPAN = 20;\n\nconst tintFor = (tempF: number, acOn: boolean): readonly [number, number, number] => {\n  if (!acOn) {\n    return [168, 176, 188];\n  }\n  const k = Math.min(1, Math.max(0, (tempF - TINT_MIN_TEMP) / TINT_TEMP_SPAN));\n  const cold: readonly [number, number, number] = [90, 140, 240];\n  const mid: readonly [number, number, number] = [150, 178, 226];\n  const hot: readonly [number, number, number] = [235, 105, 105];\n  const from = k < 0.5 ? cold : mid;\n  const to = k < 0.5 ? mid : hot;\n  const t = k < 0.5 ? k * 2 : (k - 0.5) * 2;\n  return [\n    Math.round(from[0] + (to[0] - from[0]) * t),\n    Math.round(from[1] + (to[1] - from[1]) * t),\n    Math.round(from[2] + (to[2] - from[2]) * t),\n  ];\n};\n\n// Deterministic per-strand pseudo-random, stable across frames.\nconst seeded = (index: number, channel: number) => {\n  const value = Math.sin(index * 127.1 + channel * 311.7) * 43_758.5453;\n  return value - Math.floor(value);\n};\n\nexport const VentScene: FC<VentSceneProps> = ({ tempF, fanSpeed, powerOn, acOn }) => {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const settingsRef = useRef({ acOn, fanSpeed, powerOn, tempF });\n  const steerRef = useRef({ current: 0, reach: 1, reachTarget: 1, target: 0 });\n  const intensityRef = useRef(0);\n  const smoothFanRef = useRef(fanSpeed);\n  const pointerRef = useRef({ alpha: 0, x: 0, y: 0 });\n  const [dragging, setDragging] = useState(false);\n  const draggingRef = useRef(false);\n\n  useEffect(() => {\n    settingsRef.current = { acOn, fanSpeed, powerOn, tempF };\n  }, [tempF, fanSpeed, powerOn, acOn]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const context = canvas.getContext(\"2d\");\n    if (!context) {\n      return;\n    }\n    const offscreen = document.createElement(\"canvas\");\n    const offContext = offscreen.getContext(\"2d\");\n    if (!offContext) {\n      return;\n    }\n\n    let frame = 0;\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      const dpr = window.devicePixelRatio || 1;\n      const width = Math.round(rect.width * dpr);\n      const height = Math.round(rect.height * dpr);\n      if (canvas.width !== width || canvas.height !== height) {\n        canvas.width = width;\n        canvas.height = height;\n        offscreen.width = width;\n        offscreen.height = height;\n        context.setTransform(dpr, 0, 0, dpr, 0, 0);\n        offContext.setTransform(dpr, 0, 0, dpr, 0, 0);\n      }\n      return rect;\n    };\n\n    const step = (timeMs: number) => {\n      frame = requestAnimationFrame(step);\n      const rect = resize();\n      const w = rect.width;\n      const h = rect.height;\n      if (w === 0 || h === 0) {\n        return;\n      }\n\n      const time = timeMs * 0.001;\n      const { tempF: temp, fanSpeed: fan, powerOn: on, acOn: cool } = settingsRef.current;\n\n      intensityRef.current += ((on ? 1 : 0) - intensityRef.current) * 0.06;\n      smoothFanRef.current += (fan - smoothFanRef.current) * 0.08;\n      const steer = steerRef.current;\n      // Shortest-arc lerp so aiming across the top doesn't spin the long way round.\n      const arc = ((steer.target - steer.current + Math.PI * 3) % (Math.PI * 2)) - Math.PI;\n      steer.current += arc * 0.1;\n      steer.reach += (steer.reachTarget - steer.reach) * 0.08;\n\n      const intensity = intensityRef.current;\n      const smoothFan = smoothFanRef.current;\n      const tint = tintFor(temp, cool);\n      const unit = h / 340;\n\n      // Backdrop.\n      const backdrop = context.createLinearGradient(0, 0, 0, h);\n      backdrop.addColorStop(0, \"#eef0f3\");\n      backdrop.addColorStop(0.35, \"#e4e8ec\");\n      backdrop.addColorStop(1, \"#d2d8df\");\n      context.fillStyle = backdrop;\n      context.fillRect(0, 0, w, h);\n\n      const slitCx = w / 2;\n      const slitY = h * SLIT_Y;\n      const slitW = w * SLIT_WIDTH;\n\n      // Ambient temperature wash under the plume.\n      if (intensity > 0.02) {\n        const wash = context.createRadialGradient(slitCx, slitY, 0, slitCx, slitY, h * 0.85);\n        wash.addColorStop(\n          0,\n          `rgba(${tint[0]},${tint[1]},${tint[2]},${(0.16 * intensity).toFixed(3)})`,\n        );\n        wash.addColorStop(1, `rgba(${tint[0]},${tint[1]},${tint[2]},0)`);\n        context.fillStyle = wash;\n        context.fillRect(0, 0, w, h);\n      }\n\n      // Air strands, drawn sharp offscreen then composited through blur.\n      if (intensity > 0.01) {\n        offContext.clearRect(0, 0, w, h);\n        offContext.globalCompositeOperation = \"lighter\";\n        offContext.lineCap = \"round\";\n\n        const reach = h * 0.52 * (0.62 + smoothFan * 0.055) * steer.reach;\n        const pulseSpeed = 1.4 + smoothFan * 0.75;\n        const flutter = 0.75 + smoothFan * 0.11;\n\n        for (let index = 0; index < STRANDS; index += 1) {\n          const across = seeded(index, 1) - 0.5;\n          const lengthSeed = 0.55 + 0.45 * seeded(index, 2);\n          const phase = seeded(index, 3) * Math.PI * 2;\n          const curl = 0.07 + seeded(index, 4) * 0.15;\n          const speedSeed = 0.7 + seeded(index, 5) * 0.7;\n          const breathe = 1 + 0.14 * Math.sin(time * 0.55 * speedSeed * flutter + phase);\n          const length = reach * lengthSeed * breathe;\n\n          let x = slitCx + across * slitW * 0.86 + Math.sin(steer.current) * 4 * unit;\n          let y = slitY + Math.cos(steer.current) * 4 * unit;\n          const strandAlpha = (0.55 + 0.45 * seeded(index, 6)) * intensity;\n\n          for (let segment = 1; segment <= SEGMENTS; segment += 1) {\n            const s = segment / SEGMENTS;\n            const angle =\n              steer.current +\n              across * (0.5 + 1.05 * s) +\n              Math.sin(time * speedSeed * 1.25 * flutter + s * 2.8 + phase) * curl * s;\n            const stepLength = length / SEGMENTS;\n            const nextX = x + Math.sin(angle) * stepLength;\n            const nextY = y + Math.cos(angle) * stepLength;\n\n            const pulse = 0.68 + 0.32 * Math.sin(s * 6.2 - time * pulseSpeed + phase);\n            const alpha = (1 - s) ** 1.55 * Math.min(s * 9, 1) * 0.5 * strandAlpha * pulse;\n            if (alpha > 0.008) {\n              offContext.strokeStyle = `rgba(255,255,255,${alpha.toFixed(3)})`;\n              offContext.lineWidth = (0.9 + s * 7.5) * unit;\n              offContext.beginPath();\n              offContext.moveTo(x, y);\n              offContext.lineTo(nextX, nextY);\n              offContext.stroke();\n            }\n            x = nextX;\n            y = nextY;\n          }\n        }\n\n        // Heavy pass = mist body, light pass = fiber detail.\n        context.save();\n        if (\"filter\" in CanvasRenderingContext2D.prototype) {\n          context.filter = \"blur(7px)\";\n          context.drawImage(offscreen, 0, 0, w, h);\n          context.filter = \"blur(1.5px)\";\n        } else {\n          context.globalAlpha = 0.3;\n          for (let dx = -2; dx <= 2; dx += 2) {\n            context.drawImage(offscreen, dx, 1, w, h);\n          }\n        }\n        context.globalAlpha = 0.75;\n        context.drawImage(offscreen, 0, 0, w, h);\n        context.restore();\n\n        // Bright rush at the slit.\n        const rush = context.createRadialGradient(slitCx, slitY, 0, slitCx, slitY, slitW * 0.42);\n        rush.addColorStop(0, `rgba(255,255,255,${(0.9 * intensity).toFixed(3)})`);\n        rush.addColorStop(0.4, `rgba(255,255,255,${(0.35 * intensity).toFixed(3)})`);\n        rush.addColorStop(1, \"rgba(255,255,255,0)\");\n        context.fillStyle = rush;\n        context.save();\n        context.translate(slitCx, slitY);\n        context.scale(1, 0.42);\n        context.translate(-slitCx, -slitY);\n        context.beginPath();\n        context.arc(slitCx, slitY, slitW * 0.42, 0, Math.PI * 2);\n        context.fill();\n        context.restore();\n      }\n\n      // The slit itself, drawn above the air so it stays crisp.\n      context.beginPath();\n      context.roundRect(slitCx - slitW / 2, slitY - 3.5 * unit, slitW, 7 * unit, 5 * unit);\n      const slot = context.createLinearGradient(0, slitY - 3.5 * unit, 0, slitY + 3.5 * unit);\n      slot.addColorStop(0, \"#4d5766\");\n      slot.addColorStop(0.5, \"#333c49\");\n      slot.addColorStop(1, \"#5b6574\");\n      context.fillStyle = slot;\n      context.fill();\n      // Bottom lip highlight.\n      context.beginPath();\n      context.roundRect(slitCx - slitW / 2, slitY + 3.5 * unit, slitW, 1.4 * unit, 1);\n      context.fillStyle = \"rgba(255,255,255,0.75)\";\n      context.fill();\n\n      // Subtle touch circle under the pointer while dragging.\n      const pointer = pointerRef.current;\n      pointer.alpha += ((draggingRef.current ? 1 : 0) - pointer.alpha) * 0.16;\n      if (pointer.alpha > 0.02) {\n        const touchRadius = 26 * unit;\n        context.beginPath();\n        context.arc(pointer.x, pointer.y, touchRadius, 0, Math.PI * 2);\n        context.fillStyle = `rgba(255,255,255,${(0.3 * pointer.alpha).toFixed(3)})`;\n        context.fill();\n        context.strokeStyle = `rgba(73,84,100,${(0.3 * pointer.alpha).toFixed(3)})`;\n        context.lineWidth = 1.5;\n        context.stroke();\n      }\n    };\n\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  // The plume aims at the pointer: direction sets the angle (any way, up included),\n  // distance from the slit sets reach.\n  const aimStream = (clientX: number, clientY: number) => {\n    const canvas = canvasRef.current;\n    if (!canvas || !draggingRef.current) {\n      return;\n    }\n    const rect = canvas.getBoundingClientRect();\n    const px = clientX - rect.left;\n    const py = clientY - rect.top;\n    pointerRef.current.x = px;\n    pointerRef.current.y = py;\n    const dx = px - rect.width / 2;\n    const dy = py - rect.height * SLIT_Y;\n    const fromSlit = Math.hypot(dx, dy);\n    if (fromSlit < 10) {\n      return;\n    }\n    steerRef.current.target = Math.atan2(dx, dy);\n    const distance = fromSlit / (rect.height * 0.52);\n    steerRef.current.reachTarget = Math.max(0.35, Math.min(1.3, distance));\n  };\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`absolute inset-0 h-full w-full touch-none ${\n        dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n      }`}\n      onPointerDown={(event) => {\n        if (!settingsRef.current.powerOn) {\n          return;\n        }\n        draggingRef.current = true;\n        setDragging(true);\n        event.currentTarget.setPointerCapture(event.pointerId);\n        aimStream(event.clientX, event.clientY);\n      }}\n      onPointerMove={(event) => {\n        aimStream(event.clientX, event.clientY);\n      }}\n      onPointerUp={() => {\n        draggingRef.current = false;\n        setDragging(false);\n      }}\n      onPointerCancel={() => {\n        draggingRef.current = false;\n        setDragging(false);\n      }}\n    />\n  );\n};\n","path":"/vent-scene.tsx","target":"uicapsule/tesla-climate/vent-scene.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/tesla-climate","name":"tesla-climate","registryDependencies":[],"title":"Tesla Climate","type":"registry:block"}