Components & interactionsGlass toggle switch
Optic toggle
A glass light switch bends the patterned surface beneath its rounded edge.

Toggle the studio light.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useEffect, useId, useRef, useState } from "react"
export default function OpticToggle({ active = true }: { active?: boolean }) {
const [on, setOn] = useState(true)
const [reduced, setReduced] = useState(true)
const [map, setMap] = useState("")
const [enhanced, setEnhanced] = useState(false)
const thumb = useRef<HTMLSpanElement>(null)
const id = "optic-" + useId().replace(/:/g, "")
useEffect(() => {
const media = matchMedia("(prefers-reduced-motion: reduce)")
const sync = () => setReduced(media.matches)
sync()
media.addEventListener("change", sync)
// SVG backdrop displacement currently has consistent rendering in Chromium.
// Other engines keep the translucent, blurred material and functional switch.
setEnhanced(/Chrome|Chromium|Edg\//.test(navigator.userAgent) && CSS.supports("backdrop-filter", "blur(1px)"))
return () => media.removeEventListener("change", sync)
}, [])
useEffect(() => {
const el = thumb.current
if (!el) return
let key = ""
const measure = () => {
const w = Math.round(el.clientWidth),
h = Math.round(el.clientHeight)
if (!w || !h || key === `${w},${h}`) return
key = `${w},${h}`
const canvas = document.createElement("canvas")
canvas.width = w * 2
canvas.height = h * 2
const context = canvas.getContext("2d")
if (!context) return
const pixels = context.createImageData(canvas.width, canvas.height)
const radius = Math.min(w, h) / 2
const distance = (x: number, y: number) => {
const qx = Math.abs(x - w / 2) - (w / 2 - radius)
const qy = Math.abs(y - h / 2) - (h / 2 - radius)
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - radius
}
for (let y = 0; y < canvas.height; y++)
for (let x = 0; x < canvas.width; x++) {
const px = (x + 0.5) / 2,
py = (y + 0.5) / 2,
inside = -distance(px, py),
i = (y * canvas.width + x) * 4
const gx = distance(px + 0.25, py) - distance(px - 0.25, py),
gy = distance(px, py + 0.25) - distance(px, py - 0.25)
const norm = Math.hypot(gx, gy) || 1
const t = Math.max(0, Math.min(1, inside / 12))
const strength = inside > 0 && inside < 12 ? Math.sin(t * Math.PI) * 0.72 : 0
pixels.data[i] = Math.round(128 - ((100 * gx) / norm) * strength)
pixels.data[i + 1] = Math.round(128 - ((100 * gy) / norm) * strength)
pixels.data[i + 2] = 128
pixels.data[i + 3] = 255
}
context.putImageData(pixels, 0, 0)
setMap(canvas.toDataURL())
canvas.width = canvas.height = 1
}
const observer = new ResizeObserver(measure)
observer.observe(el)
measure()
return () => observer.disconnect()
}, [])
const filter = enhanced && map ? `url(#${id}) blur(.7px)` : "blur(3px) saturate(1.2)"
return (
<section
className="sf-optic-toggle"
data-on={on}
data-calm={reduced || !active}
aria-label="Studio lighting control"
>
<style>{`.sf-optic-toggle{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;background:#242424;color:#fff;font-family:Arial,sans-serif;container-type:size}.sf-optic-toggle *{box-sizing:border-box}.sf-optic-toggle .ot-field{position:absolute;inset:0;background-color:#f76d25;background-image:repeating-linear-gradient(120deg,transparent 0 29px,#262421 29px 44px);transform:scale(1.2);filter:brightness(.4) saturate(.4);transition:filter 240ms ease-out}.sf-optic-toggle[data-on=true] .ot-field{filter:brightness(1) saturate(1)}.sf-optic-toggle .ot-vignette{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 45%,transparent 10%,#14161480 100%);pointer-events:none}.sf-optic-toggle .ot-switch{position:absolute;left:50%;top:47%;transform:translate(-50%,-50%);width:186px;height:80px;border-radius:44px;padding:5px;border:1px solid #fff5;background:#10151422;box-shadow:inset 0 2px 7px #0007,0 3px 0 #fff3;cursor:pointer;touch-action:manipulation;isolation:isolate}.sf-optic-toggle .ot-track{position:absolute;inset:0;display:flex;justify-content:space-between;align-items:center;padding:0 31px;color:#fff8;font:12px Arial;pointer-events:none}.sf-optic-toggle .ot-thumb{position:absolute;width:102px;height:68px;left:5px;top:5px;border-radius:36px;transform:translateX(0);transition:transform 260ms cubic-bezier(.22,1,.36,1);background:linear-gradient(145deg,#ffffff3b,#ffffff08 38%,#ffffff1f);box-shadow:inset 0 1px 1px #fff,inset 0 -2px 2px #fff9,inset 2px 0 3px #fffc,inset -2px 0 3px #0005,0 4px 7px #0004;pointer-events:none}.sf-optic-toggle[data-on=true] .ot-thumb{transform:translateX(72px)}.sf-optic-toggle .ot-thumb:after{content:'';position:absolute;inset:5px;border-radius:inherit;border-top:1px solid #fff9;background:linear-gradient(155deg,#fff4,transparent 35%);pointer-events:none}.sf-optic-toggle .ot-dot{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:7px;height:7px;border-radius:50%;background:#e3e3d8;box-shadow:0 1px 3px #0005}.sf-optic-toggle[data-on=true] .ot-dot{background:#e3ff94;box-shadow:0 0 9px #d3ff98}.sf-optic-toggle .ot-switch:focus-visible{outline:2px solid #fff;outline-offset:7px}.sf-optic-toggle .ot-switch:active .ot-thumb{box-shadow:inset 0 1px 2px #fff,0 1px 2px #0008}.sf-optic-toggle .ot-label{position:absolute;top:calc(47% + 63px);left:0;right:0;display:flex;gap:9px;justify-content:center;align-items:center;font-size:11px;letter-spacing:.08em;text-shadow:0 1px 3px #0008}.sf-optic-toggle .ot-label span:last-child{opacity:.7}.sf-optic-toggle[data-calm=true] .ot-field,.sf-optic-toggle[data-calm=true] .ot-thumb{transition:none}`}</style>
<div className="ot-field" />
<div className="ot-vignette" />
{enhanced && map && (
<svg width="0" height="0" aria-hidden="true" style={{ position: "absolute" }}>
<defs>
<filter
id={id}
x="0"
y="0"
width="102"
height="68"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feImage href={map} x="0" y="0" width="102" height="68" preserveAspectRatio="none" result="edge" />
<feDisplacementMap in="SourceGraphic" in2="edge" scale="28" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
)}
<button
type="button"
role="switch"
aria-checked={on}
aria-label="Studio light"
className="ot-switch"
onClick={() => setOn((v) => !v)}
>
<span className="ot-track" aria-hidden="true">
<span>O</span>
<span>I</span>
</span>
<span ref={thumb} className="ot-thumb" style={{ backdropFilter: filter, WebkitBackdropFilter: filter }}>
<span className="ot-dot" />
</span>
</button>
<div className="ot-label" aria-hidden="true">
<span>STUDIO LIGHT</span>
<span>{on ? "ON" : "OFF"}</span>
</div>
</section>
)
}
Where it works well
Give a familiar on/off control convincing optical depth while preserving native input behavior.
Reduced motion
The switch changes position and illumination immediately.
Things to know
Rounded-edge SVG backdrop displacement is enhanced in Chromium; other browsers use translucent blur. No optical ray tracing or capture of the page into an image.