Glass Slider

A slider whose thumb becomes refracting glass while dragged, stretching along its velocity and springing back to solid.

  • Liquid Glass
  • Automotive
  • React only · 0 deps
  • 2 files
Full screen

Browser support · Real refraction (SVG displacement inside backdrop-filter) in Chromium browsers. Safari and Firefox fall back to a frosted blur with the same rim and highlights.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/glass-slider.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-glass/glass-slider.tsx
1/**
2 * GlassSlider — a slider whose thumb turns into Liquid Glass while dragged.
3 *
4 * At rest the thumb is a compact solid capsule. Grab it and it swells into
5 * clear glass that refracts the fill and track underneath, stretches along
6 * its velocity (area-preserving) and springs back to solid on release.
7 *
8 * Props: value, onChange(value), min (0), max (100), step (1), label
9 * (accessible name) or aria-labelledby, valueText(value) for
10 * aria-valuetext, accent (fill colour or CSS gradient), tone ("light" |
11 * "dark"), ticks (number of tick marks), disabled, className.
12 * Accessibility: native <input type="range"> drives everything — Arrow keys,
13 * PageUp/PageDown, Home/End and screen readers work as usual.
14 * Reduced motion: no swell or stretch, thumb moves instantly.
15 * Browser support: refraction in Chromium; frosted glass elsewhere.
16 */
17import { useEffect, useRef, useState } from "react";
18import type { CSSProperties, ReactNode } from "react";
19import { GlassScope, LiquidGlass, createVelocityTracker, useGlassEnv, useSpring } from "./liquid-glass";
20
21export interface GlassSliderProps {
22 value: number;
23 onChange: (value: number) => void;
24 min?: number;
25 max?: number;
26 step?: number;
27 label?: string;
28 "aria-labelledby"?: string;
29 valueText?: (v: number) => string;
30 /** Fill colour or gradient. */
31 accent?: string;
32 tone?: "light" | "dark";
33 ticks?: number;
34 disabled?: boolean;
35 className?: string;
36 id?: string;
37}
38
39const KW = 38; // thumb width at rest
40const KH = 24;
41
42export function GlassSlider({
43 value,
44 onChange,
45 min = 0,
46 max = 100,
47 step = 1,
48 label,
49 valueText,
50 accent = "#0A84FF",
51 tone = "light",
52 ticks = 0,
53 disabled,
54 className,
55 id,
56 ...aria
57}: GlassSliderProps) {
58 const env = useGlassEnv();
59 const root = useRef<HTMLDivElement | null>(null);
60 const thumb = useRef<HTMLElement | null>(null);
61 const input = useRef<HTMLInputElement | null>(null);
62 const [active, setActive] = useState(false);
63 const pct = (value - min) / (max - min || 1);
64
65 const width = useRef(0);
66 const paint = () => {
67 const t = thumb.current;
68 const r = root.current;
69 if (!t || !r) return;
70 const lift = sp.value;
71 const v = env.reducedMotion ? 0 : px.velocity;
72 const st = Math.min(0.32, Math.abs(v) * 0.0005);
73 const s = 1 + lift * 0.55;
74 const x = px.value * (width.current - KW);
75 t.style.transform = `translateX(${x.toFixed(2)}px) scale(${(s * (1 + st)).toFixed(4)}, ${(s / (1 + st * 0.7)).toFixed(4)})`;
76 t.style.setProperty("--fd-gsl-solid", Math.max(0, 1 - lift * 1.3).toFixed(3));
77 r.style.setProperty("--fd-gsl-x", `${(x + KW / 2).toFixed(2)}px`);
78 };
79 const px = useSpring(pct, paint, { stiffness: 520, damping: 34, precision: 0.0002 });
80 const sp = useSpring(0, paint, { stiffness: 520, damping: 30 });
81
82 // measure + follow external value changes
83 useEffect(() => {
84 const r = root.current;
85 if (!r) return;
86 const measure = () => {
87 width.current = r.clientWidth;
88 paint();
89 };
90 measure();
91 const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
92 ro?.observe(r);
93 return () => ro?.disconnect();
94 // eslint-disable-next-line react-hooks/exhaustive-deps
95 }, []);
96 const dragging = useRef(false);
97 useEffect(() => {
98 if (dragging.current) return;
99 if (env.reducedMotion) px.set(pct);
100 else px.to(pct);
101 }, [pct, env.reducedMotion, px]);
102
103 // latest props for the long-lived pointer handlers
104 const live = useRef({ value, min, max, step, disabled, onChange });
105 live.current = { value, min, max, step, disabled, onChange };
106
107 // pointer: drag anywhere on the track; thumb follows the finger continuously, value snaps to step
108 useEffect(() => {
109 const r = root.current;
110 if (!r) return;
111 const vt = createVelocityTracker();
112 let pid = -1;
113 let grabOffset = 0;
114 const toPct = (clientX: number) => {
115 const b = r.getBoundingClientRect();
116 return Math.min(1, Math.max(0, (clientX - b.left - grabOffset - KW / 2) / (b.width - KW)));
117 };
118 const emit = (p: number) => {
119 const { value, min, max, step, onChange } = live.current;
120 const raw = min + p * (max - min);
121 const snapped = Math.min(max, Math.max(min, Math.round((raw - min) / step) * step + min));
122 if (snapped !== value) onChange(Number(snapped.toFixed(6)));
123 };
124 const down = (e: PointerEvent) => {
125 if (live.current.disabled || e.button !== 0) return;
126 e.preventDefault();
127 pid = e.pointerId;
128 r.setPointerCapture(pid);
129 r.dataset.pointer = "1";
130 input.current?.focus({ preventScroll: true });
131 const b = r.getBoundingClientRect();
132 const thumbX = b.left + px.value * (b.width - KW) + KW / 2;
133 // grabbing the thumb keeps the offset; tapping the track jumps there
134 grabOffset = Math.abs(e.clientX - thumbX) < KW ? e.clientX - thumbX : 0;
135 dragging.current = true;
136 setActive(true);
137 vt.reset();
138 vt.push(e.clientX, 0);
139 if (env.reducedMotion) sp.set(1);
140 else sp.to(1);
141 const p = toPct(e.clientX);
142 px.stiffness = 700;
143 px.damping = 40;
144 if (env.reducedMotion) px.set(p);
145 else px.to(p);
146 emit(p);
147 };
148 const move = (e: PointerEvent) => {
149 if (e.pointerId !== pid) return;
150 vt.push(e.clientX, 0);
151 const p = toPct(e.clientX);
152 if (env.reducedMotion) px.set(p);
153 else px.to(p);
154 emit(p);
155 };
156 const up = (e: PointerEvent) => {
157 if (e.pointerId !== pid) return;
158 pid = -1;
159 dragging.current = false;
160 setActive(false);
161 px.stiffness = 520;
162 px.damping = 34;
163 if (env.reducedMotion) sp.set(0);
164 else sp.to(0);
165 };
166 r.addEventListener("pointerdown", down);
167 r.addEventListener("pointermove", move);
168 r.addEventListener("pointerup", up);
169 r.addEventListener("pointercancel", up);
170 return () => {
171 r.removeEventListener("pointerdown", down);
172 r.removeEventListener("pointermove", move);
173 r.removeEventListener("pointerup", up);
174 r.removeEventListener("pointercancel", up);
175 };
176 }, [env.reducedMotion, px, sp]);
177
178 // after a drag ends, glide to the snapped position
179 useEffect(() => {
180 if (!active && !dragging.current) px.to(pct);
181 }, [active, pct, px]);
182
183 return (
184 <GlassScope>
185 <div
186 ref={root}
187 className={["fd-gsl", `fd-gsl--${tone}`, active ? "is-active" : "", disabled ? "is-disabled" : "", className ?? ""].filter(Boolean).join(" ")}
188 style={{ "--fd-gsl-accent": accent, "--fd-gsl-kw": `${KW}px`, "--fd-gsl-kh": `${KH}px`, "--fd-gsl-x": `calc(${pct} * (100% - ${KW}px) + ${KW / 2}px)` } as CSSProperties}
189 >
190 <style>{css}</style>
191 <span className="fd-gsl-track" aria-hidden="true">
192 <span className="fd-gsl-fill" />
193 {ticks > 1 && (
194 <span className="fd-gsl-ticks">
195 {Array.from({ length: ticks }, (_, i) => (
196 <i key={i} />
197 ))}
198 </span>
199 )}
200 </span>
201 <LiquidGlass ref={thumb} as="span" className="fd-gsl-thumb" variant="clear" radius="capsule" refraction={1.3} dispersion={0.1} shadow={false} aria-hidden="true">
202 <span className="fd-gsl-solid" />
203 </LiquidGlass>
204 <input
205 ref={input}
206 id={id}
207 className="fd-gsl-input"
208 type="range"
209 min={min}
210 max={max}
211 step={step}
212 value={value}
213 disabled={disabled}
214 aria-label={label}
215 aria-valuetext={valueText?.(value)}
216 onChange={(e) => onChange(Number(e.target.value))}
217 onKeyDown={() => root.current?.removeAttribute("data-pointer")}
218 {...aria}
219 />
220 </div>
221 </GlassScope>
222 );
223}
224
225const css = `
226.fd-gsl,.fd-gsl *,.fd-gsl *::before,.fd-gsl *::after{box-sizing:border-box}
227.fd-gsl{--fd-gsl-track:rgba(120,120,128,0.2);position:relative;display:block;width:100%;height:36px;margin:0;padding:0;touch-action:none;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
228.fd-gsl--dark{--fd-gsl-track:rgba(255,255,255,0.18)}
229.fd-gsl.is-disabled{opacity:0.45;cursor:default}
230.fd-gsl-track{position:absolute;left:0;right:0;top:50%;height:6px;margin:-3px 0 0;padding:0;border-radius:999px;background:var(--fd-gsl-track);overflow:hidden;display:block}
231.fd-gsl-fill{position:absolute;left:0;top:0;bottom:0;width:var(--fd-gsl-x);display:block;margin:0;padding:0;border-radius:inherit;background:var(--fd-gsl-accent)}
232.fd-gsl-ticks{position:absolute;inset:0;display:flex;justify-content:space-between;align-items:center;margin:0;padding:0 calc(var(--fd-gsl-kw) / 2 - 1px)}
233.fd-gsl-ticks i{display:block;width:2px;height:2px;margin:0;border-radius:2px;background:rgba(255,255,255,0.7)}
234.fd-gsl-thumb{position:absolute !important;left:0;top:50%;display:block;width:var(--fd-gsl-kw);height:var(--fd-gsl-kh);margin:calc(var(--fd-gsl-kh) / -2) 0 0;padding:0;pointer-events:none;will-change:transform;
235 --fd-lg-shadow:0 3px 8px rgba(0,0,0,0.16),0 1px 1px rgba(0,0,0,0.12)}
236.fd-gsl.is-active .fd-gsl-thumb{--fd-lg-shadow:0 10px 24px -8px rgba(0,0,0,0.3)}
237.fd-gsl-solid{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;background:#FFFFFF;opacity:var(--fd-gsl-solid,1)}
238.fd-gsl-thumb .fd-lg-spec{opacity:calc(1 - var(--fd-gsl-solid,1) * 0.85)}
239.fd-gsl-thumb .fd-lg-fill{background:rgba(255,255,255,0.04)}
240.fd-gsl-input{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0;opacity:0;pointer-events:none;-webkit-appearance:none;appearance:none;background:none}
241.fd-gsl:not([data-pointer]):has(.fd-gsl-input:focus-visible) .fd-gsl-thumb{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:3px}
242@media (prefers-contrast: more){.fd-gsl-track{box-shadow:inset 0 0 0 1px rgba(0,0,0,0.6)}.fd-gsl-solid{opacity:1 !important}}
243@media (prefers-reduced-transparency: reduce){.fd-gsl-solid{opacity:1 !important}}
244`;
245
246/* ───────────────────────── demo: EV climate controls ───────────────────────── */
247
248const Ico = ({ children }: { children: ReactNode }) => (
249 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
250 {children}
251 </svg>
252);
253
254export function Demo() {
255 const [temp, setTemp] = useState(21.5);
256 const [fan, setFan] = useState(3);
257 const [seat, setSeat] = useState(2);
258 const [charge, setCharge] = useState(80);
259 const warm = (temp - 16) / 12; // 0..1
260 const range = Math.round(charge * 5.12);
261 return (
262 <div className="fd-gsld" style={{ "--warm": warm } as CSSProperties}>
263 <style>{demoCss}</style>
264 <div className="fd-gsld-glow" aria-hidden="true" />
265 <div className="fd-gsld-car" aria-hidden="true">
266 <svg viewBox="0 0 600 220" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
267 <defs>
268 <linearGradient id="fdgsldbody" x1="0" y1="0" x2="0" y2="1">
269 <stop offset="0" stopColor="#3A3A44" />
270 <stop offset="0.5" stopColor="#1C1C22" />
271 <stop offset="1" stopColor="#0B0B0F" />
272 </linearGradient>
273 <linearGradient id="fdgsldglass" x1="0" y1="0" x2="1" y2="1">
274 <stop offset="0" stopColor="#8EC5FF" stopOpacity="0.55" />
275 <stop offset="1" stopColor="#1B2A44" stopOpacity="0.9" />
276 </linearGradient>
277 </defs>
278 <path d="M40 150c6-30 40-44 90-50l70-46c22-14 46-20 80-20h70c40 0 70 10 100 32l46 34c50 6 82 18 90 46l4 22c0 10-8 16-18 16H58c-12 0-20-6-20-16Z" fill="url(#fdgsldbody)" />
279 <path d="M210 64c18-12 38-16 66-16h52v52H160Z" fill="url(#fdgsldglass)" />
280 <path d="M346 48h20c34 0 58 8 84 26l30 26H346Z" fill="url(#fdgsldglass)" />
281 <path d="M60 140h480" stroke="rgba(255,255,255,0.12)" strokeWidth="2" />
282 <circle cx="150" cy="180" r="38" fill="#0B0B0F" />
283 <circle cx="150" cy="180" r="24" fill="#3A3A44" stroke="#6B6B76" strokeWidth="3" />
284 <circle cx="458" cy="180" r="38" fill="#0B0B0F" />
285 <circle cx="458" cy="180" r="24" fill="#3A3A44" stroke="#6B6B76" strokeWidth="3" />
286 <rect x="530" y="128" width="26" height="6" rx="3" fill="#FF453A" />
287 <rect x="44" y="124" width="30" height="6" rx="3" fill="#F2F2F7" />
288 </svg>
289 </div>
290
291 <header className="fd-gsld-head">
292 <p className="fd-gsld-eyebrow">Voltra GT · Pre-conditioning</p>
293 <h1 className="fd-gsld-temp">
294 {temp.toFixed(1)}
295 <span>°C</span>
296 </h1>
297 <p className="fd-gsld-sub">Cabin ready by 07:45 · 61% · {range} km range</p>
298 </header>
299
300 <section className="fd-gsld-panel" aria-label="Climate">
301 <div className="fd-gsld-row">
302 <label className="fd-gsld-lbl" id="fd-gsld-t">
303 <Ico>
304 <path d="M10 4a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z" />
305 </Ico>
306 Temperature
307 </label>
308 <span className="fd-gsld-val">{temp.toFixed(1)} °C</span>
309 <GlassSlider
310 value={temp}
311 onChange={setTemp}
312 min={16}
313 max={28}
314 step={0.5}
315 aria-labelledby="fd-gsld-t"
316 valueText={(v) => `${v.toFixed(1)} degrees Celsius`}
317 tone="dark"
318 accent="linear-gradient(90deg,#0A84FF,#64D2FF 40%,#FFD60A 70%,#FF453A)"
319 />
320 </div>
321 <div className="fd-gsld-row">
322 <label className="fd-gsld-lbl" id="fd-gsld-f">
323 <Ico>
324 <circle cx="12" cy="12" r="1.8" />
325 <path d="M12 10.2c0-4 1-6.7 3.4-6.7 2.6 0 2.8 3.6.2 5.6M13.8 12c4 0 6.7 1 6.7 3.4 0 2.6-3.6 2.8-5.6.2M12 13.8c0 4-1 6.7-3.4 6.7-2.6 0-2.8-3.6-.2-5.6M10.2 12c-4 0-6.7-1-6.7-3.4 0-2.6 3.6-2.8 5.6-.2" />
326 </Ico>
327 Fan
328 </label>
329 <span className="fd-gsld-val">{fan === 0 ? "Off" : `Level ${fan}`}</span>
330 <GlassSlider value={fan} onChange={setFan} min={0} max={7} step={1} ticks={8} aria-labelledby="fd-gsld-f" valueText={(v) => (v === 0 ? "Off" : `Level ${v} of 7`)} tone="dark" accent="#64D2FF" />
331 </div>
332 <div className="fd-gsld-row">
333 <label className="fd-gsld-lbl" id="fd-gsld-s">
334 <Ico>
335 <path d="M7 3.5v8.5a3 3 0 0 0 3 3h7l2 5.5M7 9h4" />
336 <path d="M14 3.5c-1 1.3 1 2.2 0 3.5M17 3.5c-1 1.3 1 2.2 0 3.5" />
337 </Ico>
338 Seat heating
339 </label>
340 <span className="fd-gsld-val">{["Off", "Low", "Medium", "High"][seat]}</span>
341 <GlassSlider value={seat} onChange={setSeat} min={0} max={3} step={1} ticks={4} aria-labelledby="fd-gsld-s" valueText={(v) => ["Off", "Low", "Medium", "High"][v]} tone="dark" accent="#FF9F0A" />
342 </div>
343 <div className="fd-gsld-row">
344 <label className="fd-gsld-lbl" id="fd-gsld-c">
345 <Ico>
346 <rect x="3" y="7" width="16" height="10" rx="2.5" />
347 <path d="M21 10.5v3M11.5 9 9 12.5h4L10.5 16" />
348 </Ico>
349 Charge limit
350 </label>
351 <span className="fd-gsld-val">{charge}%</span>
352 <GlassSlider value={charge} onChange={setCharge} min={50} max={100} step={5} aria-labelledby="fd-gsld-c" valueText={(v) => `${v} percent, about ${Math.round(v * 5.12)} kilometres`} tone="dark" accent="#30D158" />
353 </div>
354 </section>
355 </div>
356 );
357}
358
359const F = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
360const D = `"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif`;
361const demoCss = `
362.fd-gsld,.fd-gsld *,.fd-gsld *::before,.fd-gsld *::after{box-sizing:border-box}
363.fd-gsld{position:relative;min-height:100vh;width:100%;margin:0;padding:48px 24px;display:grid;grid-template-columns:minmax(0,600px) minmax(0,440px);grid-template-areas:"head panel" "car panel";gap:24px 56px;align-content:center;justify-content:center;overflow:hidden;
364 background:#07080C;color:#F2F2F7;font:400 15px/1.4 ${F};letter-spacing:normal}
365.fd-gsld-glow{position:absolute;inset:-20%;pointer-events:none;background:
366 radial-gradient(40% 40% at 30% 60%,color-mix(in srgb,#0A84FF calc((1 - var(--warm)) * 70%),#FF6A3D calc(var(--warm) * 70%)) 0%,transparent 70%),
367 radial-gradient(30% 30% at 80% 20%,rgba(100,210,255,0.18),transparent 70%);opacity:0.75;transition:background 420ms}
368.fd-gsld-head{grid-area:head;position:relative;align-self:end;margin:0;padding:0}
369.fd-gsld-eyebrow{margin:0 0 6px;font:600 13px/1.3 ${F};letter-spacing:0.06em;text-transform:uppercase;color:#8E8E99}
370.fd-gsld-temp{margin:0;font:200 clamp(5.5rem,11vw,8.5rem)/0.9 ${D};letter-spacing:-0.05em;color:#fff;font-variant-numeric:tabular-nums}
371.fd-gsld-temp span{font:300 0.32em/1 ${D};letter-spacing:-0.01em;vertical-align:top;margin-left:6px;color:#AEAEB8}
372.fd-gsld-sub{margin:10px 0 0;font:500 15px/1.4 ${F};color:#AEAEB8}
373.fd-gsld-car{grid-area:car;position:relative;max-width:620px;height:220px;margin:0;opacity:0.95}
374.fd-gsld-car svg{display:block}
375.fd-gsld-panel{grid-area:panel;position:relative;align-self:center;margin:0;padding:8px 22px;border-radius:28px;background:rgba(28,28,34,0.82);box-shadow:inset 0 0 0 0.5px rgba(255,255,255,0.1),0 30px 60px -30px rgba(0,0,0,0.8)}
376.fd-gsld-row{display:grid;grid-template-columns:1fr auto;grid-template-areas:"l v" "s s";gap:4px 12px;margin:0;padding:16px 0}
377.fd-gsld-row + .fd-gsld-row{border-top:0.5px solid rgba(255,255,255,0.1)}
378.fd-gsld-lbl{grid-area:l;display:flex;align-items:center;gap:10px;margin:0;padding:0;font:600 15px/1.3 ${F};letter-spacing:-0.005em;color:#F2F2F7;cursor:default}
379.fd-gsld-lbl svg{display:block;color:#AEAEB8}
380.fd-gsld-val{grid-area:v;font:500 15px/1.3 ${F};color:#AEAEB8;font-variant-numeric:tabular-nums}
381.fd-gsld-row .fd-gsl{grid-area:s}
382@media (max-width:900px){
383 .fd-gsld{grid-template-columns:minmax(0,1fr);grid-template-areas:"head" "car" "panel";gap:12px;padding:32px 16px;align-content:start}
384 .fd-gsld-car{height:120px;max-width:none}
385 .fd-gsld-panel{padding:4px 18px}
386}
387`;

More Liquid Glass. Same file contract.

Liquid Glass

The material itself: a lens that refracts what’s behind it, with squircle bevel, specular rim, dispersion and spring physics.

Glass Button

glass, glassProminent, clear and clear-prominent capsules in four sizes, with a springy press and a glow from the touch point.

Glass Tab Bar

Floating iOS 26 tab bar: a liquid lens springs between tabs, search sits apart, and it minimizes as you scroll.