Thermostat
A smart-home thermostat: drag the dial or tap plus and minus, switch heat, cool or off, and see when it reaches target.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/thermostat.json'use client'
import { useRef, useState } from 'react'
import './thermostat.css'
export interface ThermostatProps {
target?: number
current?: number
unit?: 'C' | 'F'
mode?: 'heat' | 'cool' | 'off'
min?: number
max?: number
onChange?: (t: number) => void
fill?: 'dark' | 'light'
className?: string
}
const START = 135, SWEEP = 270
/** Dial thermostat with mode and ETA. */
export function Thermostat({ target = 21, current = 19, unit = 'C', mode: m0 = 'heat', min = 10, max = 30, onChange, fill = 'dark', className }: ThermostatProps) {
const [t, setT] = useState(target)
const [mode, setMode] = useState(m0)
const ref = useRef<HTMLDivElement>(null)
const drag = useRef(false)
const set = (v: number) => { const c = Math.round(Math.max(min, Math.min(max, v)) * 2) / 2; setT(c); onChange?.(c) }
const frac = (v: number) => (v - min) / (max - min)
const ang = (f: number) => ((START + f * SWEEP) * Math.PI) / 180
const pt = (f: number, r: number) => [Math.round((110 + r * Math.cos(ang(f))) * 100) / 100, Math.round((110 + r * Math.sin(ang(f))) * 100) / 100]
const fromPointer = (e: React.PointerEvent) => {
const b = ref.current!.getBoundingClientRect()
let a = (Math.atan2(e.clientY - b.top - b.height / 2, e.clientX - b.left - b.width / 2) * 180) / Math.PI
a = (a - START + 360) % 360
if (a > SWEEP) a = a > SWEEP + 45 ? 0 : SWEEP
set(min + (a / SWEEP) * (max - min))
}
const ticks = 54
const diff = t - current
const est = mode === 'off' ? 'System off' : (mode === 'heat' && diff <= 0) || (mode === 'cool' && diff >= 0) ? `Holding at ${current}°` : `${mode === 'heat' ? 'Heating' : 'Cooling'} · about ${Math.ceil(Math.abs(diff) * 12)} min to ${t}°`
const [kx, ky] = pt(frac(t), 92), [cx1, cy1] = pt(frac(current), 84), [cx2, cy2] = pt(frac(current), 100)
return (
<div className={['sd-thm', className].filter(Boolean).join(' ')} data-fill={fill} data-mode={mode}>
<div className="sd-thm__h"><span>Living room</span><span>Inside <b>{current}°{unit}</b></span></div>
<div ref={ref} className="sd-thm__dial" role="slider" tabIndex={0} aria-label="Set point" aria-valuemin={min} aria-valuemax={max} aria-valuenow={t} aria-valuetext={`${t} degrees ${unit}, ${mode}`}
onPointerDown={(e) => { if (mode === 'off') return; drag.current = true; (e.currentTarget as Element).setPointerCapture?.(e.pointerId); fromPointer(e) }}
onPointerMove={(e) => drag.current && fromPointer(e)} onPointerUp={() => (drag.current = false)}
onKeyDown={(e) => { const d = { ArrowUp: 0.5, ArrowRight: 0.5, ArrowDown: -0.5, ArrowLeft: -0.5 }[e.key]; if (d && mode !== 'off') { e.preventDefault(); set(t + d) } }}>
<svg viewBox="0 0 220 220" aria-hidden>
{Array.from({ length: ticks + 1 }, (_, i) => { const f = i / ticks, [x1, y1] = pt(f, 96), [x2, y2] = pt(f, 106); const lo = Math.min(frac(t), frac(current)), hi = Math.max(frac(t), frac(current)); return <line key={i} className="sd-thm__tk" x1={x1} y1={y1} x2={x2} y2={y2} data-on={(f >= lo - 0.001 && f <= hi + 0.001) || undefined} /> })}
<line className="sd-thm__cur" x1={cx1} y1={cy1} x2={cx2} y2={cy2} />
<circle className="sd-thm__knob" cx={kx} cy={ky} r="7" />
</svg>
<div className="sd-thm__mid"><small>{mode === 'off' ? 'Off' : mode === 'heat' ? 'Heat to' : 'Cool to'}</small><b>{t % 1 ? t.toFixed(1) : t}<sup>°</sup></b></div>
</div>
<div className="sd-thm__pm"><button type="button" aria-label="Lower" disabled={mode === 'off' || t <= min} onClick={() => set(t - 0.5)}>−</button><button type="button" aria-label="Raise" disabled={mode === 'off' || t >= max} onClick={() => set(t + 0.5)}>+</button></div>
<div className="sd-thm__md" role="radiogroup" aria-label="Mode">{(['heat', 'cool', 'off'] as const).map((x) => <button key={x} type="button" role="radio" aria-checked={mode === x} onClick={() => setMode(x)}>{x[0].toUpperCase() + x.slice(1)}</button>)}</div>
<p className="sd-thm__est" aria-live="polite" style={{ margin: 0 }}>{est}</p>
</div>
)
}.sd-thm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-thm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-thm :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-thm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-thm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-thm { width: min(100%, 300px); padding: 16px; grid-template-columns: minmax(0, 1fr); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 12px; justify-items: center; }
.sd-thm__h { width: 100%; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--mute); }
.sd-thm__h b { color: var(--fg); }
.sd-thm__dial { position: relative; width: 220px; height: 220px; touch-action: none; border-radius: 50%; outline: 0; }
.sd-thm__dial:focus-visible { box-shadow: 0 0 0 2px var(--fg); }
.sd-thm__dial svg { width: 100%; height: 100%; display: block; }
.sd-thm__tk { stroke: var(--line); stroke-width: 2; stroke-linecap: round; }
.sd-thm__tk[data-on] { stroke: var(--fg); }
.sd-thm[data-mode='off'] .sd-thm__tk[data-on] { stroke: var(--mute); }
.sd-thm__cur { stroke: var(--mute); stroke-width: 4; stroke-linecap: round; }
.sd-thm__knob { fill: var(--fg); }
.sd-thm__mid { position: absolute; inset: 0; display: grid; place-items: center; align-content: center; text-align: center; pointer-events: none; }
.sd-thm__mid small { font-size: 11.5px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.08em; }
.sd-thm__mid b { font-size: 52px; font-weight: 600; letter-spacing: -0.04em; line-height: 1; font-variant-numeric: tabular-nums; }
.sd-thm__mid b sup { font-size: 22px; font-weight: 500; vertical-align: 0.9em; line-height: 0; margin-left: 2px; }
.sd-thm[data-mode='off'] .sd-thm__mid b { color: var(--mute); }
.sd-thm__pm { display: flex; gap: 10px; margin-top: -8px; }
.sd-thm__pm button { width: 44px; height: 44px; border-radius: 50%; font-size: 20px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-thm__pm button:hover:not(:disabled) { background: var(--soft); }
.sd-thm__pm button:disabled { opacity: 0.3; }
.sd-thm__md { width: 100%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 3px; border-radius: 12px; background: var(--soft); }
.sd-thm__md button { height: 32px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--mute) !important; }
.sd-thm__md button[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; }
.sd-thm__est { font-size: 12.5px; color: var(--mute); min-height: 18px; }
@media (prefers-reduced-motion: reduce) { .sd-thm *, .sd-thm *::before, .sd-thm *::after { animation: none !important; transition: none !important; } }Overview
The dial is a 270-degree arc of ticks; the set point is a solid knob and the room temperature a small notch. Drag around the arc or use arrow keys.
Modes are a segmented control. An estimate line says how long until the room reaches target, or that it is holding.
When to use
- Smart-home dashboards, device detail screens.
When not to use
- Multi-zone schedules: use a schedule editor.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/thermostat.json$
npx shadcn@latest add https://sundaeui.dev/r/thermostat.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/thermostat.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/thermostat.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy thermostat.tsx and thermostat.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { Thermostat } from '@/components/sundae/thermostat'
<Thermostat />Props
| Prop | Type | Default |
|---|---|---|
targetSet point. | number | 21 |
currentRoom temperature. | number | 19 |
unitScale. | 'C' | 'F' | 'C' |
onChangeCalled when the set point changes. | (t: number) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The dial is a slider with aria-valuetext including mode and unit.
- Mode buttons are a radiogroup.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-thm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← → / ↑ ↓
- Change the set point by 0.5°
FAQ
- Is Thermostat free?
- Yes. Thermostat is free for personal and commercial projects, with full source on this page.
- How do I install Thermostat?
- Run npx shadcn@latest add https://sundaeui.dev/r/thermostat.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Thermostat support keyboard and screen readers?
- The dial is a slider with aria-valuetext including mode and unit. Mode buttons are a radiogroup.
- Does Thermostat respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Light Scenes
Components · RefinedSmart Lock
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined