Cursors✨ Premium

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.

JSClip-path

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

Creative studio hero — outlined name, revealed on hover — Cursor Text Reveal example 1

① Creative studio hero — outlined name, revealed on hover

WhenStudio homepage: the name very large on two lines, a tagline underneath.
WhyThe outline asserts the typography without imposing it; the fill appears only under the visitor's hand. The name is repeated in plain text in the tagline and menu: nothing is lost for those who don't hover.
Settings72 px text on two lines (container width fixed), 80px radius, rgba(255,255,255,.35) outline, content: attr(data-text) / "".
404 page — the error code reveals itself — Cursor Text Reveal example 2

② 404 page — the error code reveals itself

WhenPage not found: a large “404”, an explanatory sentence, a back link.
WhyA 404 can afford to play: the hollow number invites sweeping, the fill reveals itself. The explanation and the link stay in ordinary text, so the page does its job without hover.
Settings140 px text, 100px radius, outline raised to rgba(255,255,255,.45) (4.5:1) so the code stays readable at rest.
Invitation card — a word revealed by light — Cursor Text Reveal example 3

③ Invitation card — a word revealed by light

WhenPrivate event page: a key word in spaced capitals, the date, the venue.
WhyThe gesture evokes ink appearing under a lamp; in gold on black, the outline already draws the word and the fill makes it shine. The useful details (date, venue) are in plain text.
Settingsletter-spacing: .3em, outline rgba(251,191,36,.5), fill #fde68a, 70px radius, .15s transition.

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 StaticText nodes “REVEAL ME” — the real text and the ::before. Use the alternative-text syntax content: 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 #545457 on #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 to rgba(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 mousemove then, on the next tap outside the zone, a mouseleave — 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: none prevents 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: 2rem follows browser zoom, but the 50 px radius and pixel positions don't — at 200%, the circle covers half as much text. Express the radius in em (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.

Chrome 87+✓ Full
Firefox 66+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS✓ Circle frozen at the tap point
Android Chrome✓ Circle frozen at the tap point

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):

index.html — structure
<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>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Almost always in 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.
CSS-generated content is part of the accessible name (measured: two “REVEAL ME” nodes in Chromium's accessibility tree). The syntax 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.
The JS only handles one pair of ids (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.