Components & interactionsCursor focus lens
Focus lens
A moving aperture brings fine silver lines into focus over an ultramarine optical plate.

Move the lens, use arrow keys, or reveal the full artwork
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, useRef, useState } from "react"
function useReducedMotion() {
const [reduced, setReduced] = useState(true)
useEffect(() => {
const query = matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return reduced
}
function OpticalPlate() {
return (
<svg viewBox="0 0 600 360" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="600" height="360" fill="#1538b9" />
{Array.from({ length: 65 }, (_, i) => (
<path
key={i}
d={`M ${-110 + i * 8} -30 C ${520 - i * 5} ${90 + i * 0.6}, ${80 + i * 6} ${240 - i * 0.8}, ${
710 - i * 7
} 390`}
fill="none"
stroke={i % 4 === 0 ? "#e8f5fa" : "#9bb9dc"}
strokeWidth={i % 4 === 0 ? 1.8 : 0.8}
/>
))}
<circle cx="435" cy="125" r="57" fill="#dcedfb" />
<circle cx="449" cy="116" r="46" fill="#1538b9" />
{Array.from({ length: 20 }, (_, i) => (
<line key={i} x1={430 + i * 3.4} x2={360 + i * 3.4} y1="245" y2="315" stroke="#cfe8ff" strokeWidth=".7" />
))}
</svg>
)
}
export default function FocusLens({ active = true }: { active?: boolean }) {
const reduced = useReducedMotion()
const root = useRef<HTMLElement>(null)
const veil = useRef<HTMLDivElement>(null)
const ring = useRef<HTMLDivElement>(null)
const point = useRef({ x: 0.48, y: 0.43 })
const target = useRef({ x: 0.48, y: 0.43 })
const request = useRef<(x: number, y: number, instant?: boolean) => void>(() => {})
const [clear, setClear] = useState(false)
useEffect(() => {
const el = root.current
if (!el) return
let raf = 0,
last = 0
const paint = () => {
el.querySelector("[role=slider]")?.setAttribute("aria-valuenow", String(Math.round(point.current.x * 100)))
el.querySelector("[role=slider]")?.setAttribute(
"aria-valuetext",
`Horizontal ${Math.round(point.current.x * 100)} percent, vertical ${Math.round(
point.current.y * 100,
)} percent`,
)
const x = point.current.x * el.clientWidth,
y = point.current.y * el.clientHeight
if (veil.current)
veil.current.style.maskImage = `radial-gradient(circle 82px at ${x}px ${y}px, transparent 0, transparent 52%, black 100%)`
if (ring.current) ring.current.style.transform = `translate(${x}px,${y}px) translate(-50%,-50%)`
}
const tick = (now: number) => {
const factor = last ? 1 - Math.exp(-Math.min((now - last) / 1000, 0.05) * 18) : 0.18
last = now
point.current.x += (target.current.x - point.current.x) * factor
point.current.y += (target.current.y - point.current.y) * factor
paint()
if (Math.abs(target.current.x - point.current.x) + Math.abs(target.current.y - point.current.y) > 0.0002)
raf = requestAnimationFrame(tick)
else raf = 0
}
request.current = (x, y, instant = false) => {
target.current = { x: Math.max(0.05, Math.min(0.95, x)), y: Math.max(0.05, Math.min(0.82, y)) }
if (instant || reduced || !active) {
point.current = { ...target.current }
paint()
return
}
if (!raf) {
last = 0
raf = requestAnimationFrame(tick)
}
}
const observer = new ResizeObserver(paint)
observer.observe(el)
paint()
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
}
}, [active, reduced])
return (
<section ref={root} className="sf-focus-lens" data-clear={clear} data-still={!active || reduced}>
<div className="sf-focus-art">
<OpticalPlate />
</div>
<div className="sf-focus-veil" ref={veil}>
<OpticalPlate />
</div>
<div
className="sf-focus-input"
role="slider"
tabIndex={0}
aria-label="Focus lens position"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(point.current.x * 100)}
aria-valuetext="Use arrow keys to move the lens"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId)
const r = e.currentTarget.getBoundingClientRect()
request.current((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height)
}}
onPointerMove={(e) => {
if (e.pointerType !== "mouse" && !e.currentTarget.hasPointerCapture(e.pointerId)) return
if (!active) return
const r = e.currentTarget.getBoundingClientRect()
request.current((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height)
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
}}
onKeyDown={(e) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home"].includes(e.key)) return
e.preventDefault()
request.current(
e.key === "Home"
? 0.48
: target.current.x + (e.key === "ArrowRight" ? 0.08 : e.key === "ArrowLeft" ? -0.08 : 0),
e.key === "Home"
? 0.43
: target.current.y + (e.key === "ArrowDown" ? 0.08 : e.key === "ArrowUp" ? -0.08 : 0),
true,
)
}}
/>
<div ref={ring} className="sf-focus-ring" aria-hidden="true" />
<button type="button" className="sf-focus-button" aria-pressed={clear} onClick={() => setClear(!clear)}>
{clear ? "Use lens" : "Reveal all"}
<span aria-hidden="true">{clear ? "⊙" : "↗"}</span>
</button>
<style>{`.sf-focus-lens{position:relative;width:100%;height:100%;min-height:260px;background:#1538b9;overflow:hidden;isolation:isolate}.sf-focus-lens *{box-sizing:border-box}.sf-focus-art,.sf-focus-veil{position:absolute;inset:0}.sf-focus-art svg,.sf-focus-veil svg{width:100%;height:100%;display:block}.sf-focus-veil svg{filter:blur(13px)}.sf-focus-veil{background:#1538b9;mask-image:radial-gradient(circle 82px at 48% 43%,transparent 0,transparent 52%,black 100%)}.sf-focus-input{position:absolute;inset:0;cursor:crosshair;touch-action:none}.sf-focus-ring{position:absolute;left:0;top:0;width:126px;height:126px;border:1px solid #e5f0ff75;border-radius:50%;pointer-events:none;box-shadow:0 0 0 4px #13258f18}.sf-focus-lens[data-clear=true] .sf-focus-veil,.sf-focus-lens[data-clear=true] .sf-focus-ring{display:none}.sf-focus-button{position:absolute;left:22px;right:22px;bottom:12px;display:flex;align-items:center;justify-content:space-between;min-height:44px;padding:0 12px;border:1px solid #c9e3ff60;border-radius:0;background:#1538b9cc;color:#fff;font:12px Arial,sans-serif;cursor:pointer}.sf-focus-button span{font-size:20px}.sf-focus-lens :focus-visible{outline:2px solid #fff;outline-offset:-5px}`}</style>
</section>
)
}
Where it works well
Exploring fine imagery, material details, or a progressive focus reveal.
Reduced motion
The aperture follows directly instead of easing. Reveal all provides a still full view.
Things to know
Uses a blurred duplicate and radial CSS mask; does not magnify pixels or simulate refraction.