Cursor Text Reveal
A 32 px title reduced to a 30% white outline, doubled by a solid-text ::before that clip-path: circle(50px) cuts out at the pointer position (100 ms transition): the word appears only under the circle and vanishes on exit, the circle being sent back to −100 px.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
The #zone-text-reveal area (#0a0a0f background in an inline style) holds .cursor-reveal-container with the attribute data-text="REVEAL ME" and the same text as content. The container carries the “ghost” text: font-size: 2rem, font-weight: 800, color: transparent, -webkit-text-stroke: 1px rgba(255,255,255,.3), user-select: none. Its ::before carries the solid text: content: attr(data-text), position: absolute; inset: 0, color: #fff, -webkit-text-stroke: 0, and above all clip-path: circle(50px at var(--cursor-x, -100px) var(--cursor-y, -100px)) with transition: clip-path .1s.
The JS listens to mousemove on the zone, not the text, but computes coordinates relative to the text container: rect = textRevealContainer.getBoundingClientRect(), x = clientX − rect.left, then setProperty('--cursor-x', x + 'px') on the container. mouseleave sends both variables back to -100px. Measured: clip-path: circle(50px at 19px 18.5px) while hovering, circle(50px at -100px -100px) after leaving — the circle is then entirely outside the text box, nothing solid remains visible.
Two layers of the same word, stacked to the pixel: the outline is the real text, the fill is the pseudo-element. inset: 0 gives it the container's exact box, and since it inherits font, weight and size, the glyphs land in the same positions. Absolute condition: data-text must be identical to the content, otherwise the two texts drift apart. The 100 ms transition on clip-path works because both endpoints are circle()s of the same radius: the browser interpolates the center.
The listened zone, .cursor-zone, sets width: 100% and a 300 px height: it fills its parent, and the listener covers that whole surface, not just the text's column (186 px wide) — the coordinates, for their part, stay computed relative to the text container. No cursor: none, no requestAnimationFrame (0 calls/s measured): the system arrow stays, the circle is glued to the pointer, the only smoothing is the CSS transition.
Accessibility
- The title is read twice: CSS-generated content is exposed to assistive technology, and Chromium's accessibility tree shows two
StaticTextnodes “REVEAL ME” — the real text and the::before. Use the alternative-text syntaxcontent: attr(data-text) / ""(Chrome 77+, Safari 17.4+, Firefox 128+) so screen readers skip the pseudo-element. - Contrast at rest: the 1 px outline at
rgba(255,255,255,.3)composites to#545457on#0a0a0f, 2.6:1, and it is hollow — the word is unreadable without hover, by design. The revealed text measures 19.8:1. If the title carries meaning, raise the outline torgba(255,255,255,.5)(5.3:1) or show the fill without hover through@media (hover: none)and(prefers-reduced-motion: reduce):clip-path: none. - Touch: a tap emulates a
mousemovethen, on the next tap outside the zone, amouseleave— the user sees a 50 px circle frozen at the tapped point, never the whole word. Keyboard: nothing is revealed. Hence the fallback@media (hover: none) { .cursor-reveal-container::before { clip-path: none } }. user-select: noneprevents selecting and copying the title, even once revealed; remove the declaration if the text must stay copyable. The pseudo-element is never selectable — the real content is what counts for copying.- Zoom and reflow:
font-size: 2remfollows browser zoom, but the 50 px radius and pixel positions don't — at 200%, the circle covers half as much text. Express the radius inem(circle(1.6em …)) so it grows with the font.
Browser compatibility
Requires unprefixed clip-path: circle() with interpolation, -webkit-text-stroke (supported under that name everywhere, Firefox 49+ included), the inset shorthand and CSS variables. ES2015 JS. Zero dependencies.
Without clip-path (pre-2017 browsers), the solid text shows in full, permanently: an ordinary readable white title. Without JavaScript, the variables stay at −100 px: only the outline is visible, nothing is revealed. Without inset (Safari before 14.1, Firefox before 66), the pseudo-element loses its box and lands at the container's top-left, misaligned from the outline.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="cursor-zone" id="zone-text-reveal" style="background: #0a0a0f;">
<div class="cursor-reveal-container" data-text="REVEAL ME" id="textRevealContainer">REVEAL ME</div>
<span class="info-text" style="color: rgba(255,255,255,0.5);">Text reveal</span>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Circle radius (CSS — circle(50px …)) |
50 px | Area revealed around the pointer. At 32 px text, 50 px uncovers two or three letters; 120px reveals a whole word. In em, the radius follows the font size. |
Outline (CSS — -webkit-text-stroke: 1px rgba(255,255,255,.3)) |
1 px at 30% | Visibility of the ghost text. .5 = 5.3:1, readable at rest; 0 = invisible text until hover (the fill becomes the only trace). |
Transition (CSS — ::before clip-path .1s) |
100 ms | Lag of the circle behind the pointer. .3s gives a visible trail; 0 glues the circle. Interpolable only between two circle()s of the same shape. |
Text (HTML — content + data-text) |
REVEAL ME | Both must stay identical, including spaces and case; otherwise outline and fill drift apart. For translated text, change both. |
Size and weight (CSS — font-size: 2rem; font-weight: 800) |
32 px, 800 | The ::before inherits both: change them on the container only. A heavy weight is needed for a 1 px outline to stay readable. |
Resting position (JS — '-100px' in mouseleave; CSS — var(--cursor-x, -100px) fallbacks) |
−100 px | Where the circle goes when the mouse leaves. For text taller than 100 px, move it further (-200px) so the circle fully exits the box. |
Fill color (CSS — ::before color) |
#fff | Revealed color. A gradient goes on with background-clip: text on the pseudo-element; the clip-path cut applies on top without conflict. |
FAQ
data-text: the ::before shows attr(data-text), the outline shows the HTML content; if one was changed without the other (translation, space, case), the two words no longer coincide. Second cause: a different width that changes line wrapping — the pseudo-element takes the container's box through inset: 0, so fix the container's width if the text spans several lines. Third: a padding on the container, which inset: 0 ignores — put it on the parent.content: attr(data-text) / "" declares an empty alternative text: the pseudo-element stays displayed but is no longer read (Chrome 77+, Safari 17.4+, Firefox 128+). Browsers that don't know the syntax drop the whole declaration — keep the line content: attr(data-text) right before it as a fallback.zone-text-reveal, textRevealContainer). For several instances, loop over document.querySelectorAll('.cursor-reveal-container') and listen to mousemove on each container or on a common parent, recomputing the relevant container's getBoundingClientRect() on every event. As for the listening area, it's already designed for it: the listener is on the zone, not the text, and the shipped rule gives it width: 100% — the parent's whole surface reacts. Shrink or grow that zone as you like: the circle stays computed relative to the text.