# Tesla Climate

> A replica of Tesla's climate screen — drag the airstream to aim the vents, with live temperature, fan, and seat-heat response.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/tesla-climate).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/tesla-climate
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/tesla-climate-light-fd1f9fe408f630b8.png
- Registry item: https://uicapsule.com/r/tesla-climate.json
- Categories: controls, skeuomorphism
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uicapsule/tesla-climate.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@number-flow/react`, `lucide-react`

## Files

### `/preview.tsx`

```tsx
"use client";

import { TeslaClimate } from "./tesla-climate";

const Preview = () => (
  <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">
    <TeslaClimate />
    <p className="mt-6 text-xs text-white/25">Drag anywhere to aim the air</p>
  </main>
);

export default Preview;
```

### `/tesla-climate.tsx`

```tsx
"use client";

import { useState } from "react";
import NumberFlow from "@number-flow/react";
import { ChevronLeft, ChevronRight, Fan, Power } from "lucide-react";

import { VentScene } from "./vent-scene";

const MIN_TEMP = 60;
const MAX_TEMP = 80;

const tempLabel = (tempF: number) => {
  if (tempF <= MIN_TEMP) {
    return "LO";
  }
  if (tempF >= MAX_TEMP) {
    return "HI";
  }
  return String(tempF);
};

export const TeslaClimate = () => {
  const [powerOn, setPowerOn] = useState(true);
  const [acOn, setAcOn] = useState(true);
  const [tempF, setTempF] = useState(68);
  const [fanSpeed, setFanSpeed] = useState(4);

  const adjustTemp = (delta: number) => {
    setTempF((previous) => Math.min(MAX_TEMP, Math.max(MIN_TEMP, previous + delta)));
  };

  const adjustFan = (delta: number) => {
    setFanSpeed((previous) => Math.min(10, Math.max(1, previous + delta)));
  };

  const label = tempLabel(tempF);

  return (
    <div className="w-[min(620px,94vw)] overflow-hidden rounded-[24px] shadow-2xl shadow-black/50 ring-1 ring-black/15 select-none">
      <div className="relative h-[380px]">
        <VentScene tempF={tempF} fanSpeed={fanSpeed} powerOn={powerOn} acOn={acOn} />

        <div className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-2 px-4">
          <button
            type="button"
            aria-pressed={powerOn}
            aria-label={powerOn ? "Turn climate off" : "Turn climate on"}
            onClick={() => setPowerOn((previous) => !previous)}
            className={`grid h-10 w-11 place-items-center rounded-lg shadow-sm ring-1 transition-colors ${
              powerOn
                ? "bg-[#3d63f5] text-white ring-[#3d63f5]"
                : "bg-white/70 text-[#5b6472] ring-black/5 hover:bg-white/90"
            }`}
          >
            <Power className="size-4" />
          </button>

          <button
            type="button"
            aria-pressed={acOn}
            onClick={() => setAcOn((previous) => !previous)}
            className={`h-10 rounded-lg px-3.5 text-[13px] font-medium shadow-sm ring-1 transition-colors disabled:opacity-60 ${
              acOn && powerOn
                ? "bg-[#96a3f4]/50 text-[#2c3ea8] ring-[#8291ea]/40"
                : "bg-white/70 text-[#5b6472] ring-black/5 hover:bg-white/90"
            }`}
            disabled={!powerOn}
          >
            A/C
          </button>

          <div className="flex h-10 items-center rounded-lg bg-white/70 shadow-sm ring-1 ring-black/5">
            <button
              type="button"
              aria-label="Decrease temperature"
              onClick={() => adjustTemp(-1)}
              className="grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50"
              disabled={!powerOn}
            >
              <ChevronLeft className="size-4" />
            </button>
            <div className="w-8 text-center text-[15px] font-semibold text-[#3c4452] tabular-nums">
              {label === "LO" || label === "HI" ? (
                <span>{label}</span>
              ) : (
                <NumberFlow value={tempF} />
              )}
            </div>
            <button
              type="button"
              aria-label="Increase temperature"
              onClick={() => adjustTemp(1)}
              className="grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50"
              disabled={!powerOn}
            >
              <ChevronRight className="size-4" />
            </button>
          </div>

          <div className="flex h-10 items-center rounded-lg bg-white/70 shadow-sm ring-1 ring-black/5">
            <button
              type="button"
              aria-label="Decrease fan speed"
              onClick={() => adjustFan(-1)}
              className="grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50"
              disabled={!powerOn}
            >
              <ChevronLeft className="size-4" />
            </button>
            <span className="flex items-center gap-1.5 px-1 text-[13px] font-medium text-[#5b6472]">
              <Fan
                className={`size-4 ${powerOn ? "animate-spin" : ""}`}
                style={
                  powerOn
                    ? { animationDuration: `${(4.4 - fanSpeed * 0.36).toFixed(2)}s` }
                    : undefined
                }
              />
              <span className="w-4 text-center tabular-nums">
                <NumberFlow value={fanSpeed} />
              </span>
            </span>
            <button
              type="button"
              aria-label="Increase fan speed"
              onClick={() => adjustFan(1)}
              className="grid h-full w-8 place-items-center text-[#8a92a0] transition-colors hover:text-[#3c4452] disabled:opacity-50"
              disabled={!powerOn}
            >
              <ChevronRight className="size-4" />
            </button>
          </div>
        </div>

        {!powerOn && (
          <p className="pointer-events-none absolute inset-x-0 top-[46%] text-center text-xs tracking-widest text-[#8a93a1] uppercase">
            Climate off
          </p>
        )}
      </div>
    </div>
  );
};
```

### `/vent-scene.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import type { FC } from "react";

interface VentSceneProps {
  tempF: number;
  fanSpeed: number;
  powerOn: boolean;
  acOn: boolean;
}

const STRANDS = 38;
const SEGMENTS = 16;
const SLIT_Y = 0.5;
const SLIT_WIDTH = 0.46;

// Mirrors the MIN_TEMP/MAX_TEMP range in tesla-climate.tsx; duplicated rather than
// imported so the tint math stays independent of the control panel.
const TINT_MIN_TEMP = 60;
const TINT_TEMP_SPAN = 20;

const tintFor = (tempF: number, acOn: boolean): readonly [number, number, number] => {
  if (!acOn) {
    return [168, 176, 188];
  }
  const k = Math.min(1, Math.max(0, (tempF - TINT_MIN_TEMP) / TINT_TEMP_SPAN));
  const cold: readonly [number, number, number] = [90, 140, 240];
  const mid: readonly [number, number, number] = [150, 178, 226];
  const hot: readonly [number, number, number] = [235, 105, 105];
  const from = k < 0.5 ? cold : mid;
  const to = k < 0.5 ? mid : hot;
  const t = k < 0.5 ? k * 2 : (k - 0.5) * 2;
  return [
    Math.round(from[0] + (to[0] - from[0]) * t),
    Math.round(from[1] + (to[1] - from[1]) * t),
    Math.round(from[2] + (to[2] - from[2]) * t),
  ];
};

// Deterministic per-strand pseudo-random, stable across frames.
const seeded = (index: number, channel: number) => {
  const value = Math.sin(index * 127.1 + channel * 311.7) * 43_758.5453;
  return value - Math.floor(value);
};

export const VentScene: FC<VentSceneProps> = ({ tempF, fanSpeed, powerOn, acOn }) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const settingsRef = useRef({ acOn, fanSpeed, powerOn, tempF });
  const steerRef = useRef({ current: 0, reach: 1, reachTarget: 1, target: 0 });
  const intensityRef = useRef(0);
  const smoothFanRef = useRef(fanSpeed);
  const pointerRef = useRef({ alpha: 0, x: 0, y: 0 });
  const [dragging, setDragging] = useState(false);
  const draggingRef = useRef(false);

  useEffect(() => {
    settingsRef.current = { acOn, fanSpeed, powerOn, tempF };
  }, [tempF, fanSpeed, powerOn, acOn]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) {
      return;
    }
    const context = canvas.getContext("2d");
    if (!context) {
      return;
    }
    const offscreen = document.createElement("canvas");
    const offContext = offscreen.getContext("2d");
    if (!offContext) {
      return;
    }

    let frame = 0;
    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      const width = Math.round(rect.width * dpr);
      const height = Math.round(rect.height * dpr);
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
        offscreen.width = width;
        offscreen.height = height;
        context.setTransform(dpr, 0, 0, dpr, 0, 0);
        offContext.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
      return rect;
    };

    const step = (timeMs: number) => {
      frame = requestAnimationFrame(step);
      const rect = resize();
      const w = rect.width;
      const h = rect.height;
      if (w === 0 || h === 0) {
        return;
      }

      const time = timeMs * 0.001;
      const { tempF: temp, fanSpeed: fan, powerOn: on, acOn: cool } = settingsRef.current;

      intensityRef.current += ((on ? 1 : 0) - intensityRef.current) * 0.06;
      smoothFanRef.current += (fan - smoothFanRef.current) * 0.08;
      const steer = steerRef.current;
      // Shortest-arc lerp so aiming across the top doesn't spin the long way round.
      const arc = ((steer.target - steer.current + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
      steer.current += arc * 0.1;
      steer.reach += (steer.reachTarget - steer.reach) * 0.08;

      const intensity = intensityRef.current;
      const smoothFan = smoothFanRef.current;
      const tint = tintFor(temp, cool);
      const unit = h / 340;

      // Backdrop.
      const backdrop = context.createLinearGradient(0, 0, 0, h);
      backdrop.addColorStop(0, "#eef0f3");
      backdrop.addColorStop(0.35, "#e4e8ec");
      backdrop.addColorStop(1, "#d2d8df");
      context.fillStyle = backdrop;
      context.fillRect(0, 0, w, h);

      const slitCx = w / 2;
      const slitY = h * SLIT_Y;
      const slitW = w * SLIT_WIDTH;

      // Ambient temperature wash under the plume.
      if (intensity > 0.02) {
        const wash = context.createRadialGradient(slitCx, slitY, 0, slitCx, slitY, h * 0.85);
        wash.addColorStop(
          0,
          `rgba(${tint[0]},${tint[1]},${tint[2]},${(0.16 * intensity).toFixed(3)})`,
        );
        wash.addColorStop(1, `rgba(${tint[0]},${tint[1]},${tint[2]},0)`);
        context.fillStyle = wash;
        context.fillRect(0, 0, w, h);
      }

      // Air strands, drawn sharp offscreen then composited through blur.
      if (intensity > 0.01) {
        offContext.clearRect(0, 0, w, h);
        offContext.globalCompositeOperation = "lighter";
        offContext.lineCap = "round";

        const reach = h * 0.52 * (0.62 + smoothFan * 0.055) * steer.reach;
        const pulseSpeed = 1.4 + smoothFan * 0.75;
        const flutter = 0.75 + smoothFan * 0.11;

        for (let index = 0; index < STRANDS; index += 1) {
          const across = seeded(index, 1) - 0.5;
          const lengthSeed = 0.55 + 0.45 * seeded(index, 2);
          const phase = seeded(index, 3) * Math.PI * 2;
          const curl = 0.07 + seeded(index, 4) * 0.15;
          const speedSeed = 0.7 + seeded(index, 5) * 0.7;
          const breathe = 1 + 0.14 * Math.sin(time * 0.55 * speedSeed * flutter + phase);
          const length = reach * lengthSeed * breathe;

          let x = slitCx + across * slitW * 0.86 + Math.sin(steer.current) * 4 * unit;
          let y = slitY + Math.cos(steer.current) * 4 * unit;
          const strandAlpha = (0.55 + 0.45 * seeded(index, 6)) * intensity;

          for (let segment = 1; segment <= SEGMENTS; segment += 1) {
            const s = segment / SEGMENTS;
            const angle =
              steer.current +
              across * (0.5 + 1.05 * s) +
              Math.sin(time * speedSeed * 1.25 * flutter + s * 2.8 + phase) * curl * s;
            const stepLength = length / SEGMENTS;
            const nextX = x + Math.sin(angle) * stepLength;
            const nextY = y + Math.cos(angle) * stepLength;

            const pulse = 0.68 + 0.32 * Math.sin(s * 6.2 - time * pulseSpeed + phase);
            const alpha = (1 - s) ** 1.55 * Math.min(s * 9, 1) * 0.5 * strandAlpha * pulse;
            if (alpha > 0.008) {
              offContext.strokeStyle = `rgba(255,255,255,${alpha.toFixed(3)})`;
              offContext.lineWidth = (0.9 + s * 7.5) * unit;
              offContext.beginPath();
              offContext.moveTo(x, y);
              offContext.lineTo(nextX, nextY);
              offContext.stroke();
            }
            x = nextX;
            y = nextY;
          }
        }

        // Heavy pass = mist body, light pass = fiber detail.
        context.save();
        if ("filter" in CanvasRenderingContext2D.prototype) {
          context.filter = "blur(7px)";
          context.drawImage(offscreen, 0, 0, w, h);
          context.filter = "blur(1.5px)";
        } else {
          context.globalAlpha = 0.3;
          for (let dx = -2; dx <= 2; dx += 2) {
            context.drawImage(offscreen, dx, 1, w, h);
          }
        }
        context.globalAlpha = 0.75;
        context.drawImage(offscreen, 0, 0, w, h);
        context.restore();

        // Bright rush at the slit.
        const rush = context.createRadialGradient(slitCx, slitY, 0, slitCx, slitY, slitW * 0.42);
        rush.addColorStop(0, `rgba(255,255,255,${(0.9 * intensity).toFixed(3)})`);
        rush.addColorStop(0.4, `rgba(255,255,255,${(0.35 * intensity).toFixed(3)})`);
        rush.addColorStop(1, "rgba(255,255,255,0)");
        context.fillStyle = rush;
        context.save();
        context.translate(slitCx, slitY);
        context.scale(1, 0.42);
        context.translate(-slitCx, -slitY);
        context.beginPath();
        context.arc(slitCx, slitY, slitW * 0.42, 0, Math.PI * 2);
        context.fill();
        context.restore();
      }

      // The slit itself, drawn above the air so it stays crisp.
      context.beginPath();
      context.roundRect(slitCx - slitW / 2, slitY - 3.5 * unit, slitW, 7 * unit, 5 * unit);
      const slot = context.createLinearGradient(0, slitY - 3.5 * unit, 0, slitY + 3.5 * unit);
      slot.addColorStop(0, "#4d5766");
      slot.addColorStop(0.5, "#333c49");
      slot.addColorStop(1, "#5b6574");
      context.fillStyle = slot;
      context.fill();
      // Bottom lip highlight.
      context.beginPath();
      context.roundRect(slitCx - slitW / 2, slitY + 3.5 * unit, slitW, 1.4 * unit, 1);
      context.fillStyle = "rgba(255,255,255,0.75)";
      context.fill();

      // Subtle touch circle under the pointer while dragging.
      const pointer = pointerRef.current;
      pointer.alpha += ((draggingRef.current ? 1 : 0) - pointer.alpha) * 0.16;
      if (pointer.alpha > 0.02) {
        const touchRadius = 26 * unit;
        context.beginPath();
        context.arc(pointer.x, pointer.y, touchRadius, 0, Math.PI * 2);
        context.fillStyle = `rgba(255,255,255,${(0.3 * pointer.alpha).toFixed(3)})`;
        context.fill();
        context.strokeStyle = `rgba(73,84,100,${(0.3 * pointer.alpha).toFixed(3)})`;
        context.lineWidth = 1.5;
        context.stroke();
      }
    };

    frame = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame);
  }, []);

  // The plume aims at the pointer: direction sets the angle (any way, up included),
  // distance from the slit sets reach.
  const aimStream = (clientX: number, clientY: number) => {
    const canvas = canvasRef.current;
    if (!canvas || !draggingRef.current) {
      return;
    }
    const rect = canvas.getBoundingClientRect();
    const px = clientX - rect.left;
    const py = clientY - rect.top;
    pointerRef.current.x = px;
    pointerRef.current.y = py;
    const dx = px - rect.width / 2;
    const dy = py - rect.height * SLIT_Y;
    const fromSlit = Math.hypot(dx, dy);
    if (fromSlit < 10) {
      return;
    }
    steerRef.current.target = Math.atan2(dx, dy);
    const distance = fromSlit / (rect.height * 0.52);
    steerRef.current.reachTarget = Math.max(0.35, Math.min(1.3, distance));
  };

  return (
    <canvas
      ref={canvasRef}
      className={`absolute inset-0 h-full w-full touch-none ${
        dragging ? "cursor-grabbing" : "cursor-grab"
      }`}
      onPointerDown={(event) => {
        if (!settingsRef.current.powerOn) {
          return;
        }
        draggingRef.current = true;
        setDragging(true);
        event.currentTarget.setPointerCapture(event.pointerId);
        aimStream(event.clientX, event.clientY);
      }}
      onPointerMove={(event) => {
        aimStream(event.clientX, event.clientY);
      }}
      onPointerUp={() => {
        draggingRef.current = false;
        setDragging(false);
      }}
      onPointerCancel={() => {
        draggingRef.current = false;
        setDragging(false);
      }}
    />
  );
};
```
