Description
This project implements a high-end interactive glass thermostat UI inspired by physical temperature gauges and futuristic control panels.
It combines glassmorphism, SVG displacement filters, and GSAP-powered interactions to create a tactile, visually rich experience.
Key highlights:
- Draggable temperature knob with inertia
- Plasma-like mercury fill using SVG turbulence
- Dynamic gradient-based temperature coloring
- Animated scale with focus magnification
- Snow particle system when temperature drops below 40°F
- Real-time status updates (Cold, Warm, Hot, etc.)
- Fully responsive and GPU-accelerated animations
This component is ideal for dashboard UIs, IoT mockups, smart home concepts, and portfolio showcases.
HTML
1<!DOCTYPE html>
2<html lang="en">
3
4 <head>
5 <meta charset="UTF-8">
6 <title>Glass Thermostat</title>
7 <link rel="stylesheet" href="./style.css">
8
9 </head>
10
11 <body>
12 <!-- Credit -->
13<!-- The existence of this pen wouldn't've been possible without the following -->
14<!-- https://codepen.io/ash_creator/pen/zYaPZLB -->
15<!-- https://codepen.io/BalintFerenczy/pen/KwdoyEN -->
16<!-- https://www.perplexity.ai/ -->
17<!-- https://aistudio.google.com/ -->
18
19<!-- Snow appears when the temperature drops below 40°F. -->
20
21<svg style="position:absolute; width:0; height:0;">
22 <defs>
23 <filter id="turbulent-displace" colorInterpolationFilters="sRGB" x="-20%" y="-20%" width="140%" height="140%">
24 <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="10" result="noise1" seed="1" />
25 <feOffset in="noise1" dx="0" dy="0" result="offsetNoise1">
26 <animate attributeName="dy" values="700; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
27 </feOffset>
28 <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="10" result="noise2" seed="1" />
29 <feOffset in="noise2" dx="0" dy="0" result="offsetNoise2">
30 <animate attributeName="dy" values="0; -700" dur="6s" repeatCount="indefinite" calcMode="linear" />
31 </feOffset>
32 <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="10" result="noise3" seed="2" />
33 <feOffset in="noise3" dx="0" dy="0" result="offsetNoise3">
34 <animate attributeName="dx" values="490; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
35 </feOffset>
36 <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="10" result="noise4" seed="2" />
37 <feOffset in="noise4" dx="0" dy="0" result="offsetNoise4">
38 <animate attributeName="dx" values="0; -490" dur="6s" repeatCount="indefinite" calcMode="linear" />
39 </feOffset>
40 <feComposite in="offsetNoise1" in2="offsetNoise2" result="part1" />
41 <feComposite in="offsetNoise3" in2="offsetNoise4" result="part2" />
42 <feBlend in="part1" in2="part2" mode="color-dodge" result="combinedNoise" />
43 <feDisplacementMap in="SourceGraphic" in2="combinedNoise" scale="30" xChannelSelector="R" yChannelSelector="B" />
44 </filter>
45 </defs>
46</svg>
47
48<div id="app">
49 <div class="thermostat-ui">
50 <div class="thermostat glass-panel">
51 <div class="thermostat-inner">
52 <div class="glass-noise"></div>
53 <div class="scale-container" id="scaleContainer"></div>
54 <div class="track" id="track">
55 <div class="mercury" id="mercury"></div>
56 </div>
57 <div class="knob-zone">
58 <div class="knob" id="knob"></div>
59 </div>
60 </div>
61 </div>
62 <div class="temp-readout">
63 <div class="temp-value" id="tempValue">70°</div>
64 <div class="temp-label">CURRENT TEMP</div>
65 <div class="status-text" id="statusText">Comfortable</div>
66 </div>
67 </div>
68</div>
69<div class="particles-container" id="uiParticles"></div>
70 <script src='https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js'></script>
71<script src='https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/Draggable.min.js'></script><script src="./script.js"></script>
72
73 </body>
74
75</html>
🎨 CSS Code
1:root { 2 --glass-bg: rgba(10, 10, 10, 0.7); 3 --glass-border: rgba(255, 255, 255, 0.08); 4 --glow-color: #00a2fa; 5} 6 7/* Global reset */ 8* { 9 box-sizing: border-box; 10} 11 12html, body { 13 margin: 0; 14 padding: 0; 15 height: 100vh; 16 background: #000; 17 color: #fff; 18 overflow: hidden; 19 font-family: "Inter", system-ui, sans-serif; 20} 21 22#app { 23 height: 100vh; 24 display: flex; 25 align-items: center; 26 justify-content: center; 27} 28 29/* Glass body */ 30.glass-panel { 31 background: var(--glass-bg); 32 backdrop-filter: blur(20px) saturate(180%); 33 -webkit-backdrop-filter: blur(20px) saturate(180%); 34 border: 1px solid var(--glass-border); 35 box-shadow: 0 8px 32px rgba(0,0,0,0.6); 36} 37 38.thermostat-ui { 39 position: relative; 40 display: flex; 41 flex-direction: column; 42 align-items: center; 43 gap: 28px; 44} 45 46/* Main body */ 47.thermostat { 48 position: relative; 49 width: 150px; 50 height: 520px; 51 border-radius: 999px; 52 overflow: visible; 53} 54 55.thermostat-inner { 56 position: relative; 57 width: 100%; 58 height: 100%; 59 border-radius: inherit; 60 overflow: visible; 61} 62 63.thermostat-inner::before { 64 content: ""; 65 position: absolute; 66 inset: 0; 67 border-radius: inherit; 68 border: 1px solid rgba(255,255,255,0.1); 69 mix-blend-mode: soft-light; 70 pointer-events: none; 71} 72 73/* Texture */ 74.glass-noise { 75 position: absolute; 76 inset: 0; 77 border-radius: inherit; 78 opacity: 0.08; 79 mix-blend-mode: overlay; 80 pointer-events: none; 81 background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); 82} 83 84/* Track */ 85.track { 86 position: absolute; 87 top: 46px; 88 bottom: 46px; 89 left: 50%; 90 transform: translateX(-50%); 91 width: 42px; 92 border-radius: 999px; 93 background: 94 radial-gradient(circle at 50% 0%, rgba(255,255,255,0.35) 0, transparent 55%), 95 radial-gradient(circle at 50% 100%, rgba(0,0,0,1) 0, rgba(0,0,0,0.9) 70%), 96 linear-gradient(180deg, rgba(255,255,255,0.04), rgba(0,0,0,0.8)); 97 background-blend-mode: screen, normal, soft-light; 98 box-shadow: inset 0 0 18px rgba(0,0,0,1), 0 0 18px rgba(0,0,0,0.8); 99 overflow: hidden; 100} 101 102/* Electric plasma fill */ 103.mercury { 104 position: absolute; 105 bottom: 0; 106 left: -45%; 107 width: 190%; 108 height: 0%; 109 background: var(--glow-color); 110 filter: url(#turbulent-displace); 111 mix-blend-mode: screen; 112 box-shadow: 0 0 45px var(--glow-color), 0 0 90px var(--glow-color); 113 transition: height 0.12s linear, box-shadow 0.3s ease, background 0.25s ease; 114 opacity: 0.95; 115} 116 117/* Flowing current over plasma */ 118.mercury::before, 119.mercury::after { 120 content: ""; 121 position: absolute; 122 inset: 0; 123 border-radius: inherit; 124 filter: blur(6px); 125 background: radial-gradient(circle at 50% 50%, rgba(255,255,255,0.3), transparent 90%); 126 mix-blend-mode: color-dodge; 127 opacity: 0.25; 128 animation: pulseElectric 3s infinite ease-in-out alternate; 129} 130 131.mercury::after { 132 filter: blur(16px); 133 opacity: 0.18; 134 animation-delay: 1.5s; 135} 136 137@keyframes pulseElectric { 138 0% { opacity: 0.15; transform: scaleY(1); } 139 100% { opacity: 0.35; transform: scaleY(1.05); } 140} 141 142/* Knob */ 143.knob-zone { 144 position: absolute; 145 top: 46px; 146 bottom: 46px; 147 left: 0; 148 right: 0; 149 pointer-events: none; 150} 151 152.knob { 153 position: absolute; 154 left: 50%; 155 transform: translate(-50%, -50%); 156 width: 72px; 157 height: 72px; 158 border-radius: 999px; 159 background: rgba(10,10,10,0.7); 160 backdrop-filter: blur(12px) saturate(260%) brightness(1.25); 161 -webkit-backdrop-filter: blur(12px) saturate(260%) brightness(1.25); 162 border: 1px solid rgba(255,255,255,0.14); 163 box-shadow: inset 0 1px 18px rgba(255,255,255,0.15), 0 8px 26px rgba(0,0,0,0.9); 164 cursor: grab; 165 pointer-events: auto; 166 transition: box-shadow 0.2s ease, transform 0.15s ease; 167} 168 169.knob:active { 170 transform: translate(-50%, -50%) scale(1.05); 171} 172 173/* Scale container and marks */ 174.scale-container { 175 position: absolute; 176 top: 46px; 177 bottom: 46px; 178 left: -90px; 179 width: 80px; 180 pointer-events: none; 181} 182 183.scale-mark { 184 position: absolute; 185 right: 0; 186 font-size: 14px; 187 color: rgba(255,255,255,0.35); 188 font-weight: 500; 189 display: flex; 190 align-items: center; 191 gap: 6px; 192 transform-origin: right center; 193 transition: all 0.1s ease; 194} 195 196.tick { 197 height: 2px; 198 background: rgba(255,255,255,0.4); 199 border-radius: 2px; 200 flex-shrink: 0; 201} 202 203/* Temperature readout */ 204.temp-readout { 205 text-align: center; 206} 207 208.temp-value { 209 font-size: 5.2rem; 210 font-weight: 700; 211 text-shadow: 0 0 48px var(--glow-color); 212 color: var(--glow-color); 213} 214 215.temp-label { 216 font-size: 0.9rem; 217 text-transform: uppercase; 218 letter-spacing: 0.34em; 219 opacity: 0.7; 220 margin-top: 10px; 221} 222 223.status-text { 224 margin-top: 8px; 225 font-size: 1.1rem; 226 text-transform: uppercase; 227 letter-spacing: 0.22em; 228 color: var(--glow-color); 229 opacity: 0.95; 230} 231 232/* More Prominent Snow Particles - full canvas */ 233.particles-container { 234 position: fixed; 235 inset: 0; 236 pointer-events: none; 237 z-index: 0; 238 overflow: hidden; 239} 240 241/* Responsive tweak */ 242@media (max-width: 480px){ 243 .thermostat { transform: scale(0.9);} 244} 245 246#app { z-index: 10; position: relative; } 247.thermostat-ui, .thermostat { z-index: 10; position: relative; } 248.particles-container { z-index: 1; } /* snow stays behind */
Javascript Code
1const CONFIG = {
2 minTemp: 20,
3 maxTemp: 110,
4 defaultTemp: 70,
5 gradientColors: ["#00eaff","#0099ff","#00ff73","#ffdd00","#ff8800","#ff0044"],
6 gradientStops: [0,0.25,0.5,0.7,0.85,1],
7 thresholds: { snow: 40 }
8};
9
10const els = {
11 track: document.getElementById("track"),
12 mercury: document.getElementById("mercury"),
13 knob: document.getElementById("knob"),
14 scaleContainer: document.getElementById("scaleContainer"),
15 tempValue: document.getElementById("tempValue"),
16 statusText: document.getElementById("statusText"),
17 uiParticles: document.getElementById("uiParticles"),
18 root: document.documentElement
19};
20
21let currentTemp = CONFIG.defaultTemp;
22let trackHeight = 0, knobBounds = { minY: 0, maxY: 0 }, scaleItems = [], colorMap;
23let snowParticleIntervalId = null;
24
25// linear interpolation helper
26const lerp = (a,b,t) => a + (b - a) * t;
27
28function createColorMap() {
29 const stops = CONFIG.gradientStops;
30 const colors = CONFIG.gradientColors.map(c => gsap.utils.splitColor(c));
31 return t => {
32 t = Math.max(0, Math.min(1, t));
33 for (let i=0; i<stops.length-1; i++) {
34 const s0 = stops[i], s1 = stops[i+1];
35 if (t >= s0 && t <= s1) {
36 const n = (t - s0) / (s1 - s0);
37 const c0 = colors[i], c1 = colors[i+1];
38 return `rgb(${Math.round(lerp(c0[0],c1[0],n))},${Math.round(lerp(c0[1],c1[1],n))},${Math.round(lerp(c0[2],c1[2],n))})`;
39 }
40 }
41 };
42}
43
44function buildScale() {
45 els.scaleContainer.innerHTML = "";
46 scaleItems = [];
47 const min = CONFIG.minTemp, max = CONFIG.maxTemp, range = max - min;
48 const rect = els.track.getBoundingClientRect();
49 const trackH = rect.height;
50 for (let t = min; t <= max; t += 2) {
51 const el = document.createElement("div");
52 el.className = "scale-mark";
53 const tick = document.createElement("div");
54 tick.className = "tick";
55 if (t % 10 === 0) tick.style.width = "18px";
56 else if (t % 5 === 0) tick.style.width = "12px";
57 else tick.style.width = "6px";
58 const y = (1 - (t - min) / range) * (trackH - 1);
59 el.style.top = `${y}px`;
60 if (t % 10 === 0) el.innerHTML = `${t}<div class="tick"></div>`;
61 el.appendChild(tick);
62 el.dataset.temp = t;
63 els.scaleContainer.appendChild(el);
64 scaleItems.push(el);
65 }
66}
67
68function updateScaleVisuals(knobY){
69 scaleItems.forEach(el => {
70 const rect = els.track.getBoundingClientRect();
71 const elY = parseFloat(el.style.top);
72 const dist = Math.abs(knobY - elY), maxDist = 70;
73 if (dist < maxDist) {
74 const p = 1 - dist / maxDist;
75 gsap.set(el, {
76 scale: 1 + p * 0.8,
77 opacity: 0.6 + p * 0.6,
78 color: "#fff",
79 textShadow: "0 0 8px var(--glow-color)"
80 });
81 } else {
82 gsap.set(el, {
83 scale: 1,
84 opacity: 0.3,
85 color: "rgba(255,255,255,0.35)",
86 textShadow: "none"
87 });
88 }
89 });
90}
91
92function updateStatusText(t){
93 let txt = "";
94 if(t < 32) txt = "Freezing";
95 else if(t < 55) txt = "Cold";
96 else if(t < 66) txt = "Cool";
97 else if(t <= 74) txt = "Comfortable";
98 else if(t < 85) txt = "Warm";
99 else if(t < 95) txt = "Hot";
100 else txt = "Extreme";
101 els.statusText.textContent = txt;
102}
103
104function applyColorTheme(color){
105 els.root.style.setProperty("--glow-color", color);
106 els.tempValue.style.color = color;
107 els.statusText.style.color = color;
108 els.mercury.style.boxShadow = `0 0 40px ${color}, 0 0 80px ${color}`;
109}
110
111function updateSystemFromY(yPos){
112 yPos = Math.max(knobBounds.minY, Math.min(knobBounds.maxY, yPos));
113 const pct = 1 - yPos / trackHeight;
114 const temp = CONFIG.minTemp + pct * (CONFIG.maxTemp - CONFIG.minTemp);
115 currentTemp = Math.round(temp);
116 const norm = (currentTemp - CONFIG.minTemp) / (CONFIG.maxTemp - CONFIG.minTemp);
117 const color = colorMap(norm);
118 els.tempValue.textContent = currentTemp + "°";
119 els.mercury.style.height = pct * 100 + "%";
120 applyColorTheme(color);
121 updateStatusText(currentTemp);
122 updateScaleVisuals(yPos);
123 updateSnowParticles(currentTemp);
124}
125
126function initLayout(){
127 const rect = els.track.getBoundingClientRect();
128 trackHeight = rect.height;
129 knobBounds = { minY: 0, maxY: trackHeight };
130 buildScale();
131 const norm = (CONFIG.defaultTemp - CONFIG.minTemp) / (CONFIG.maxTemp - CONFIG.minTemp);
132 const startY = trackHeight * (1 - norm);
133 gsap.set(els.knob, { y: startY });
134 updateSystemFromY(startY);
135}
136
137function initDrag(){
138 Draggable.create(els.knob,{
139 type: "y",
140 bounds: { minY: knobBounds.minY, maxY: knobBounds.maxY },
141 inertia: true,
142 onDrag() { updateSystemFromY(this.y); },
143 onThrowUpdate() { updateSystemFromY(this.y); }
144 });
145}
146
147/* Improved Snow Particles with more prominence and random fall */
148
149const minSnowSpawnInterval = 1.2;
150const maxSnowSpawnInterval = 5;
151const minSnowFallDuration = 4;
152const maxSnowFallDuration = 7;
153
154function createSnowParticle() {
155 const p = document.createElement("div");
156 p.className = "particle";
157 els.uiParticles.appendChild(p);
158
159 const vw = window.innerWidth, vh = window.innerHeight;
160 const size = Math.random() * 12 + 7;
161 const baseOpacity = 0.9 + Math.random() * 0.1;
162 const blurVal = Math.random() * 1.6 + 0.9;
163
164 p.style.width = p.style.height = size + "px";
165 p.style.borderRadius = "50%";
166 p.style.background = `radial-gradient(circle, rgba(255,255,255,${baseOpacity}) 0%, rgba(255,255,255,${baseOpacity*0.9}) 70%, transparent 100%)`;
167 p.style.filter = `blur(${blurVal}px)`; // +30% brightness
168 p.style.boxShadow = "0 0 24px rgba(255,255,255,0.95)"; // 30% bigger glow
169
170 // ALWAYS spawn ABOVE viewport
171 const startX = Math.random() * vw;
172 const startY = -50 - Math.random() * 150; // -50 to -200px
173
174 gsap.set(p, { x: startX, y: startY, opacity: 0, scale: 0.6 });
175
176 const swayX = 80 + Math.random() * 60;
177 const fallDuration = getSnowFallDuration(currentTemp);
178
179 gsap.timeline({ onComplete: () => p.remove() })
180 .to(p, { opacity: 1, scale: 1, duration: 0.7, ease: "power2.out" })
181 .to(p, {
182 y: vh + 80,
183 x: "+=" + (Math.random() * swayX - swayX/2),
184 rotation: Math.random() * 180,
185 opacity: 0,
186 duration: fallDuration,
187 ease: "none"
188 }, 0)
189 .to(p, {
190 x: "+=" + (Math.random() * 40 - 20),
191 yoyo: true,
192 repeat: 1,
193 duration: 2 + Math.random() * 3,
194 ease: "sine.inOut"
195 }, 0.2);
196}
197
198function getSnowFallDuration(temp) {
199 const clampedTemp = Math.max(20, Math.min(temp, 40));
200 const requiredPct = (40 - clampedTemp) / 20;
201 return lerp(maxSnowFallDuration, maxSnowFallDuration * 0.55, requiredPct); // 30% faster
202}
203
204function getSnowSpawnInterval(temp) {
205 const clampedTemp = Math.max(20, Math.min(temp, 40));
206 const requiredPct = (40 - clampedTemp) / 20;
207 return lerp(maxSnowSpawnInterval, maxSnowSpawnInterval * 0.45, requiredPct); // 55% more snow
208}
209
210function updateSnowParticles(temp) {
211 if (temp > CONFIG.thresholds.snow) {
212 // Stop snow if above threshold
213 if (snowParticleIntervalId !== null) {
214 clearInterval(snowParticleIntervalId);
215 snowParticleIntervalId = null;
216 }
217 els.uiParticles.innerHTML = "";
218 return;
219 }
220
221 if (snowParticleIntervalId !== null) clearInterval(snowParticleIntervalId);
222
223 // Spawn immediately on threshold crossing
224 createSnowParticle();
225
226 const spawnInterval = getSnowSpawnInterval(temp) * 100; // convert to ms scaled
227 snowParticleIntervalId = setInterval(() => {
228 createSnowParticle();
229 }, spawnInterval);
230}
231
232window.addEventListener("load", () => {
233 colorMap = createColorMap();
234 initLayout();
235 initDrag();
236 updateSnowParticles(currentTemp);
237});
238
239window.addEventListener("resize", () => {
240 initLayout();
241 updateSnowParticles(currentTemp);
242});
How It Works
- Glassmorphism Structure
Frosted glass effect using backdrop-filter
Noise texture overlay for realism
Soft lighting and glow shadows
- Temperature Interaction
GSAP Draggable controls the knob
Knob Y-position maps to temperature range
Mercury fill height updates in real time
- Dynamic Color System
Gradient-based color interpolation
Glow color updates across UI
Temperature text and plasma stay in sync
- Environmental Feedback
Status text updates (Cold → Comfortable → Hot)
Snow particle system activates below 40°F
Particle density increases as temperature drops
Key Features
❄️ Temperature-aware snow particles
🧊 Glassmorphism UI design
🔥 Plasma-style mercury animation
🎛️ Smooth draggable knob with inertia
🎨 Dynamic color gradients
⚡ GPU-accelerated animations
📱 Responsive and scalable
Use Cases
Smart thermostat UI concepts
IoT dashboard mockups
Sci-fi control panels
Interactive product demos
Portfolio showcase components
