Glass Toggle

An iOS 26 switch whose knob swells into clear Liquid Glass while pressed or dragged, then settles back to solid.

  • Liquid Glass
  • Smart home
  • 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-toggle.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/liquid-glass/glass-toggle.tsx
1/**
2 * GlassToggle — an iOS 26 switch whose knob turns into Liquid Glass while
3 * you touch it.
4 *
5 * At rest the knob is a solid white capsule. On press it swells, the white
6 * drains away and it becomes clear glass refracting the track beneath it; you
7 * can drag it across, and on release it springs to its side and turns solid
8 * again (HIG: transient interactive elements may become glass).
9 *
10 * Props: checked, onChange(checked), disabled, label (accessible name, or
11 * use aria-labelledby), accent (on colour, default #34C759), size ("m" | "l"),
12 * className, id. Controlled.
13 * Accessibility: <button role="switch" aria-checked>; Space/Enter toggle;
14 * focus ring. Reduced motion: no swell/spring, instant snap.
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, useGlassEnv, useSpring } from "./liquid-glass";
20
21export interface GlassToggleProps {
22 checked: boolean;
23 onChange: (checked: boolean) => void;
24 disabled?: boolean;
25 label?: string;
26 "aria-labelledby"?: string;
27 "aria-describedby"?: string;
28 accent?: string;
29 size?: "m" | "l";
30 id?: string;
31 className?: string;
32}
33
34const DIM = { m: { w: 64, h: 28, kw: 39, kh: 24, pad: 2 }, l: { w: 80, h: 36, kw: 50, kh: 31, pad: 2.5 } };
35
36export function GlassToggle({ checked, onChange, disabled, label, accent = "#34C759", size = "m", id, className, ...aria }: GlassToggleProps) {
37 const env = useGlassEnv();
38 const root = useRef<HTMLButtonElement | null>(null);
39 const knob = useRef<HTMLElement | null>(null);
40 const d = DIM[size];
41 const travel = d.w - d.kw - d.pad * 2;
42 const [active, setActive] = useState(false);
43
44 const paint = () => {
45 const k = knob.current;
46 if (!k) return;
47 const lift = sp.value; // 0 solid → 1 glass
48 const sx = 1 + lift * 0.42 + Math.min(0.18, Math.abs(kx.velocity) * 0.0006);
49 const sy = 1 + lift * 0.42 - Math.min(0.08, Math.abs(kx.velocity) * 0.0003);
50 k.style.transform = `translateX(${kx.value.toFixed(2)}px) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
51 k.style.setProperty("--fd-gtg-solid", String(Math.max(0, 1 - lift * 1.25).toFixed(3)));
52 root.current?.style.setProperty("--fd-gtg-p", String((kx.value / travel).toFixed(4)));
53 };
54 const kx = useSpring(checked ? travel : 0, paint, { stiffness: 420, damping: 26 });
55 const sp = useSpring(0, paint, { stiffness: 520, damping: 30 });
56
57 // follow `checked` from outside
58 const firstRun = useRef(true);
59 useEffect(() => {
60 if (firstRun.current || env.reducedMotion) {
61 firstRun.current = false;
62 kx.set(checked ? travel : 0);
63 return;
64 }
65 if (!drag.current.active) kx.to(checked ? travel : 0);
66 }, [checked, travel, env.reducedMotion, kx]);
67
68 const drag = useRef({ active: false, moved: false, x0: 0, k0: 0, pid: -1 });
69
70 useEffect(() => {
71 const el = root.current;
72 if (!el) return;
73 const down = (e: PointerEvent) => {
74 if (disabled || e.button !== 0) return;
75 const g = drag.current;
76 g.active = true;
77 g.moved = false;
78 g.x0 = e.clientX;
79 g.k0 = kx.value;
80 g.pid = e.pointerId;
81 el.setPointerCapture(e.pointerId);
82 setActive(true);
83 if (env.reducedMotion) sp.set(1);
84 else sp.to(1);
85 };
86 const move = (e: PointerEvent) => {
87 const g = drag.current;
88 if (!g.active || e.pointerId !== g.pid) return;
89 const dx = e.clientX - g.x0;
90 if (Math.abs(dx) > 3) g.moved = true;
91 if (!g.moved) return;
92 let x = g.k0 + dx;
93 if (x < 0) x *= 0.25;
94 if (x > travel) x = travel + (x - travel) * 0.25;
95 kx.stiffness = 1000;
96 kx.damping = 55;
97 kx.to(x);
98 };
99 const up = (e: PointerEvent) => {
100 const g = drag.current;
101 if (!g.active || e.pointerId !== g.pid) return;
102 g.active = false;
103 setActive(false);
104 kx.stiffness = 420;
105 kx.damping = 26;
106 const next = g.moved ? kx.value > travel / 2 : !checked;
107 if (env.reducedMotion) {
108 sp.set(0);
109 kx.set(next ? travel : 0);
110 } else {
111 sp.to(0);
112 kx.to(next ? travel : 0);
113 }
114 if (next !== checked) onChange(next);
115 };
116 el.addEventListener("pointerdown", down);
117 el.addEventListener("pointermove", move);
118 el.addEventListener("pointerup", up);
119 el.addEventListener("pointercancel", up);
120 return () => {
121 el.removeEventListener("pointerdown", down);
122 el.removeEventListener("pointermove", move);
123 el.removeEventListener("pointerup", up);
124 el.removeEventListener("pointercancel", up);
125 };
126 }, [checked, disabled, onChange, travel, env.reducedMotion, kx, sp]);
127
128 return (
129 <GlassScope>
130 <button
131 ref={root}
132 id={id}
133 type="button"
134 role="switch"
135 aria-checked={checked}
136 aria-label={label}
137 disabled={disabled}
138 className={["fd-gtg", `fd-gtg--${size}`, active ? "is-active" : "", className ?? ""].filter(Boolean).join(" ")}
139 style={{ "--fd-gtg-accent": accent, "--fd-gtg-w": `${d.w}px`, "--fd-gtg-h": `${d.h}px`, "--fd-gtg-kw": `${d.kw}px`, "--fd-gtg-kh": `${d.kh}px`, "--fd-gtg-pad": `${d.pad}px`, "--fd-gtg-p": checked ? 1 : 0 } as CSSProperties}
140 onClick={(e) => {
141 // pointer toggles are handled on pointerup; this is keyboard / AT activation
142 if (e.detail === 0 && !disabled) onChange(!checked);
143 }}
144 {...aria}
145 >
146 <style>{css}</style>
147 <span className="fd-gtg-track" aria-hidden="true">
148 <span className="fd-gtg-on" />
149 </span>
150 <LiquidGlass ref={knob} as="span" className="fd-gtg-knob" variant="clear" radius="capsule" refraction={1.25} dispersion={0.1} shadow={false} aria-hidden="true">
151 <span className="fd-gtg-solid" />
152 </LiquidGlass>
153 </button>
154 </GlassScope>
155 );
156}
157
158const css = `
159.fd-gtg,.fd-gtg *,.fd-gtg *::before,.fd-gtg *::after{box-sizing:border-box}
160.fd-gtg{position:relative;display:inline-block;flex:none;width:var(--fd-gtg-w);height:var(--fd-gtg-h);margin:0;padding:0;border:0;border-radius:999px;background:none;color:inherit;font:inherit;vertical-align:middle;
161 cursor:pointer;touch-action:none;-webkit-appearance:none;appearance:none;-webkit-tap-highlight-color:transparent;outline:none}
162.fd-gtg:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}
163.fd-gtg:disabled{cursor:default;opacity:0.45}
164.fd-gtg-track{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;overflow:hidden;background:rgba(120,120,128,0.16);box-shadow:inset 0 0 0 0.5px rgba(0,0,0,0.04)}
165.fd-gtg-on{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;background:var(--fd-gtg-accent);opacity:var(--fd-gtg-p);transform-origin:0 50%}
166.fd-gtg-knob{position:absolute !important;left:var(--fd-gtg-pad);top:var(--fd-gtg-pad);display:block;width:var(--fd-gtg-kw);height:var(--fd-gtg-kh);margin:0;padding:0;will-change:transform;
167 --fd-lg-shadow:0 3px 8px rgba(0,0,0,0.15),0 1px 1px rgba(0,0,0,0.12),0 3px 1px rgba(0,0,0,0.04)}
168.fd-gtg.is-active .fd-gtg-knob{--fd-lg-shadow:0 8px 22px -6px rgba(0,0,0,0.28),0 1px 2px rgba(0,0,0,0.08)}
169.fd-gtg-solid{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;background:#FFFFFF;opacity:var(--fd-gtg-solid,1)}
170.fd-gtg-knob .fd-lg-spec{opacity:calc(1 - var(--fd-gtg-solid,1) * 0.85)}
171.fd-gtg-knob .fd-lg-fill{background:rgba(255,255,255,0.04)}
172@media (prefers-contrast: more){.fd-gtg-track{box-shadow:inset 0 0 0 1px rgba(0,0,0,0.6)}.fd-gtg-solid{opacity:1 !important}}
173@media (prefers-reduced-transparency: reduce){.fd-gtg-solid{opacity:1 !important}}
174`;
175
176/* ───────────────────────── demo: smart-home settings ───────────────────────── */
177
178type Row = { id: string; title: string; sub: string; icon: ReactNode; color: string; on: boolean; accent?: string };
179
180const svg = (d: ReactNode) => (
181 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
182 {d}
183 </svg>
184);
185
186const INITIAL: Row[] = [
187 { id: "away", title: "Away mode", sub: "Lights follow your usual evening pattern", color: "#5856D6", on: true, icon: svg(<path d="M4 20V9l8-5 8 5v11M9 20v-6h6v6" />) },
188 { id: "heat", title: "Underfloor heating", sub: "Bathroom & hallway · 21.5 °C", color: "#FF9500", on: true, icon: svg(<path d="M8 4c-2 3 2 5 0 8s2 5 0 8M16 4c-2 3 2 5 0 8s2 5 0 8" />) },
189 { id: "blinds", title: "Sunrise blinds", sub: "Open gradually from 06:40", color: "#FF2D55", on: false, icon: svg(<><circle cx="12" cy="15" r="4" /><path d="M3 15h2M19 15h2M12 3v4M5.6 8.6l1.4 1.4M18.4 8.6 17 10M3 20h18" /></>) },
190 { id: "leak", title: "Leak alerts", sub: "Kitchen, laundry, water heater", color: "#0A84FF", on: true, icon: svg(<path d="M12 3.5s6 6.4 6 10.5a6 6 0 0 1-12 0c0-4.1 6-10.5 6-10.5Z" />) },
191 { id: "solar", title: "Charge car from solar only", sub: "Wait for > 3.2 kW surplus", color: "#34C759", on: false, icon: svg(<><rect x="3" y="6" width="18" height="11" rx="2" /><path d="M3 11.5h18M9 6v11M15 6v11M12 17v3" /></>) },
192];
193
194export function Demo() {
195 const [rows, setRows] = useState(INITIAL);
196 const [night, setNight] = useState(true);
197 const set = (id: string, v: boolean) => setRows((r) => r.map((x) => (x.id === id ? { ...x, on: v } : x)));
198 const on = rows.filter((r) => r.on).length;
199 return (
200 <div className="fd-gtgd">
201 <style>{demoCss}</style>
202 <div className="fd-gtgd-wrap">
203 <header className="fd-gtgd-head">
204 <p className="fd-gtgd-eyebrow">Haus Lindenhof · 4 rooms</p>
205 <h1 className="fd-gtgd-h">Automations</h1>
206 <p className="fd-gtgd-sub">
207 {on} of {rows.length} running · last change 18:42
208 </p>
209 </header>
210
211 <section className="fd-gtgd-feature">
212 <div className="fd-gtgd-feature-art" aria-hidden="true" />
213 <div className="fd-gtgd-feature-body">
214 <p className="fd-gtgd-feature-t" id="fd-gtgd-night">
215 Night scene
216 </p>
217 <p className="fd-gtgd-feature-s">Dims 14 lights to 8%, locks the front door, arms the garden cameras at 23:00.</p>
218 </div>
219 <GlassToggle size="l" checked={night} onChange={setNight} aria-labelledby="fd-gtgd-night" accent="#FF9F0A" />
220 </section>
221
222 <ul className="fd-gtgd-list" aria-label="Automations">
223 {rows.map((r) => (
224 <li key={r.id} className="fd-gtgd-row">
225 <span className="fd-gtgd-ic" style={{ background: r.color }}>
226 {r.icon}
227 </span>
228 <span className="fd-gtgd-txt">
229 <span className="fd-gtgd-t" id={`fd-gtgd-${r.id}`}>
230 {r.title}
231 </span>
232 <span className="fd-gtgd-s" id={`fd-gtgd-${r.id}-s`}>
233 {r.sub}
234 </span>
235 </span>
236 <GlassToggle checked={r.on} onChange={(v) => set(r.id, v)} aria-labelledby={`fd-gtgd-${r.id}`} aria-describedby={`fd-gtgd-${r.id}-s`} />
237 </li>
238 ))}
239 <li className="fd-gtgd-row">
240 <span className="fd-gtgd-ic" style={{ background: "#8E8E93" }}>
241 {svg(<><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>)}
242 </span>
243 <span className="fd-gtgd-txt">
244 <span className="fd-gtgd-t" id="fd-gtgd-lock">
245 Auto-lock garage
246 </span>
247 <span className="fd-gtgd-s">Requires the Hallo Gate hub</span>
248 </span>
249 <GlassToggle checked={false} onChange={() => {}} disabled aria-labelledby="fd-gtgd-lock" />
250 </li>
251 </ul>
252 <p className="fd-gtgd-foot">Press and hold a switch — the knob turns to glass. Drag it across.</p>
253 </div>
254 </div>
255 );
256}
257
258const F = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;
259const D = `"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif`;
260const demoCss = `
261.fd-gtgd,.fd-gtgd *,.fd-gtgd *::before,.fd-gtgd *::after{box-sizing:border-box}
262.fd-gtgd{min-height:100vh;width:100%;margin:0;padding:56px 20px;display:flex;justify-content:center;align-items:flex-start;
263 background:radial-gradient(900px 500px at 15% -10%,#FFE8CC 0%,transparent 60%),radial-gradient(800px 500px at 110% 20%,#DDE7FF 0%,transparent 55%),#F2F2F7;color:#0B0B0F;font:400 15px/1.4 ${F};letter-spacing:normal}
264.fd-gtgd-wrap{width:100%;max-width:560px;margin:0;padding:0}
265.fd-gtgd-head{margin:0 0 20px;padding:0 4px}
266.fd-gtgd-eyebrow{margin:0 0 4px;font:600 13px/1.3 ${F};letter-spacing:0.02em;text-transform:uppercase;color:#6B6B76}
267.fd-gtgd-h{margin:0 0 4px;font:800 2.75rem/1.05 ${D};letter-spacing:-0.035em;color:#0B0B0F}
268.fd-gtgd-sub{margin:0;font:500 15px/1.4 ${F};color:#6B6B76}
269.fd-gtgd-feature{display:flex;align-items:center;gap:16px;margin:0 0 16px;padding:14px 18px 14px 14px;border-radius:24px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.04),0 12px 32px -16px rgba(20,20,40,0.18)}
270.fd-gtgd-feature-art{flex:none;width:64px;height:64px;margin:0;border-radius:16px;background:
271 radial-gradient(circle at 70% 30%,#FFF1B8 0 10%,transparent 11%),radial-gradient(circle at 30% 70%,rgba(255,160,40,0.9) 0 22%,transparent 50%),
272 linear-gradient(160deg,#1B1F4A,#3A2A6B 60%,#FF9F0A 140%)}
273.fd-gtgd-feature-body{flex:1;min-width:0;margin:0;padding:0}
274.fd-gtgd-feature-t{margin:0 0 2px;font:700 17px/1.25 ${F};letter-spacing:-0.01em;color:#0B0B0F}
275.fd-gtgd-feature-s{margin:0;font:400 13px/1.4 ${F};color:#6B6B76}
276.fd-gtgd-list{list-style:none;margin:0;padding:6px 0;border-radius:24px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.04),0 12px 32px -16px rgba(20,20,40,0.18)}
277.fd-gtgd-row{position:relative;display:flex;align-items:center;gap:14px;margin:0;padding:12px 18px 12px 14px;min-height:64px}
278.fd-gtgd-row + .fd-gtgd-row::before{content:"";position:absolute;left:62px;right:0;top:0;height:0.5px;background:#E2E2E8}
279.fd-gtgd-ic{flex:none;display:grid;place-items:center;width:34px;height:34px;margin:0;border-radius:9px;color:#fff}
280.fd-gtgd-ic svg{display:block}
281.fd-gtgd-txt{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;margin:0;padding:0}
282.fd-gtgd-t{display:block;font:600 16px/1.3 ${F};letter-spacing:-0.01em;color:#0B0B0F}
283.fd-gtgd-s{display:block;font:400 13px/1.35 ${F};color:#6B6B76}
284.fd-gtgd-foot{margin:16px 0 0;padding:0 4px;font:400 13px/1.4 ${F};color:#8A8A94;text-align:center}
285@media (max-width:480px){.fd-gtgd{padding:32px 16px}.fd-gtgd-h{font-size:2.25rem}}
286`;

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.