Lens Text

Text held out of focus until the pointer passes over it like a magnifying glass.

Loading preview

Installation

npx shadcn@latest add @loomui/lens-text

Usage

import { LensText } from "@/components/ui/lens-text"
<LensText>He was dead the whole time.</LensText>

Two copies of the same words sit on top of each other. The bottom one is blurred and is the real text: filters are invisible to assistive technology, so the accessible name, the selection and the copy-paste all stay there. The top one is sharp, aria-hidden, and masked down to a circle at the pointer.

That same circle is punched out of the blurred copy. Without the hole the sharp glyphs would sit on a blurred set of glyphs and read as a halo rather than as focus.

The blurred copy sits in a padded, rounded panel, and the blur goes on a span inside it rather than on the panel itself. Blur the panel and its glow spreads past its own edges, where the mask cuts it off square. Frosted glass with four hard corners. That is the tell that this is two divs.

Position and radius are written to CSS custom properties from a single requestAnimationFrame loop, never to React state. Moving the mouse across a paragraph costs zero renders.

Weight and pace

follow is the time constant of that loop, in milliseconds. The lens eases toward the pointer instead of being pinned to it, and opens and closes on the same clock a little slower. It reads as glass being moved rather than a mask being switched on.

<LensText follow={220}>Slow, heavy glass.</LensText>

Around 130 feels attached to the pointer. Past 250 the lens starts trailing far enough behind that aiming it becomes work. follow={0} locks it to the pointer and snaps it open, which is also what prefers-reduced-motion: reduce does.

Shaping the lens

size is the diameter. feather is how much of it fades at the edge. blur is how far out of focus the rest of the text sits, and radius rounds the frosted panel.

<LensText size={180} feather={0.2} blur={10} radius="1em">
  A wide lens with a near-hard edge.
</LensText>

feather: 0 gives a hard cut, which reads as a hole in frosted glass. feather: 1 falls off from the centre and reads as a soft wash. Push blur past about 12 and the resting text stops looking like words, which is the right call for a spoiler and the wrong one for a headline. radius takes any CSS length, and in em it tracks whatever type size it is wrapped around.

Magnifying

magnify scales the text under the lens, anchored to the pointer. The lens then reads as curved glass rather than a window.

<LensText ring magnify={1.08}>
  Held at arm's length.
</LensText>

Keep it under about 1.1. Past that the magnified glyphs no longer line up with the blurred ones around the edge of the lens, and the illusion breaks. ring draws a hairline circle at the lens edge, which sells the glass and gives the pointer something to hold.

Props

PropTypeDefaultDescription
blurnumber8Blur radius of the resting text, in pixels.
sizenumber110Diameter of the lens in pixels.
feathernumber0.5How much of the lens edge fades out, 0 to 1.
magnifynumber1Scale under the lens. 1 is a flat window.
follownumber130Ms the lens takes to catch up. 0 locks it.
radiusstring"0.35em"Corner radius of the frosted panel.
ringbooleanfalseDraw a hairline ring at the lens edge.
disabledbooleanfalseRender the text sharp, with no lens.
classNamestringnoneMerged onto the wrapper. Type styles here.

Any other <span> prop is forwarded.

Accessibility

Blurred text is unreadable to anyone who cannot aim a pointer at it. Use it for copy people can afford to miss. Spoilers, teasers, a redaction gag. Never put it on something load-bearing. On touch, pen and coarse pointers the component detects the missing hover and renders the text sharp instead, and disabled does the same on demand. The sharp copy is aria-hidden and pointer-events-none, so a screen reader reads the words once, unblurred.