Quick Start
1. Add to your HTML <head>:
Code snippet omitted: it ships with the download.
The one-line script is not optional. Everything that depends on JavaScript is gated behind has-js, so if the class is missing (or the effect script never loads) the two layers stay in normal flow as stacked, fully readable blocks instead of one layer sitting invisible on top of the other.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Add the markup anywhere in your <body>:
Code snippet omitted: it ships with the download.
Three structural rules, all of which the effect depends on:
.csm-surfacemust come first and stay in normal flow. It defines the height of the box;.csm-beneathis lifted on top of it withinset: 0.- Give both layers the same structure. The reveal reads as the same block re-lettered only if the lines land in the same places. Keep headline lines short enough not to wrap, and keep both bodies to a similar length.
.csm-inneris what parallaxes. Content must live inside it, not directly in.csm-layer. The mask lives on.csm-layer, so transforming the layer itself would move the aperture with it.
Using It With Your Own Design
Keep .csm-stage, the two .csm-layer elements, .csm-surface, .csm-beneath and their .csm-inner wrappers. The surface stays in normal flow and sets the stage height; the beneath layer must retain absolute positioning, inset: 0 and its mask inside the .has-js gate. The rim is optional and must keep pointer-events: none.
The Mona Sans typography, yellow reveal, copy grid, captions and comparison rows are demo styling. Replace them with your own content and colours, keeping the two layers' dimensions and text positions aligned. Preserve the no-JavaScript and reduced-motion rules so both compositions remain readable without animation. Keep any reveal button inside the stage's parent .csm-block.
Options
Set these on the .csm-stage element.
| Attribute | Values | Default | Description |
|---|---|---|---|
data-spotlight-mask |
(presence) | required | Marks the container. Without it nothing initialises. |
data-radius |
px number, or a percentage | 180 |
Rest radius of the aperture. 26% resolves against the container's shorter edge. Capped at 42% of that edge so narrow screens keep a usable aperture. |
data-stretch |
true, false |
true |
Stretch the aperture along the axis of movement. false keeps it circular and lets it grow with speed only. |
data-ease |
seconds | 0.42 |
How far the aperture trails the pointer. The lag is what generates the velocity the stretch reads, so below about 0.15 the aperture sticks to the cursor and barely stretches at all. |
data-drift |
true, false |
true |
The autonomous wander: always on for touch devices, and on desktop after 2.5 seconds without cursor movement. false parks the aperture where it was left. |
data-rim |
true, false |
true |
The trailing accent ring on the aperture edge. |
And on the button:
| Attribute | Values | Default | Description |
|---|---|---|---|
data-spotlight-toggle |
(presence) | optional | Marks the open/close control. It is paired with the container by living inside the same parent element. |
data-label-on |
any text | Hide it again |
Button label while the aperture is open. The closed label is whatever text the button ships with. |
Customising the aperture edge
The aperture's softness lives in one CSS declaration:
Code snippet omitted: it ships with the download.
82% is where the mask stops being fully opaque, so the hidden layer is solid across almost the whole aperture and only a thin ring blends. Raise it towards 100% for a hard-edged porthole. Dropping it much below 70% widens the blend zone until both layers show at half strength over a large area, and overlapping type there reads as a rendering fault rather than a reveal. If you change it, change OPAQUE_STOP in script.js to match: that constant is how the script works out the radius needed to cover the whole composition when the button is pressed.
The script never touches mask-image. It only writes mask-position and mask-size (and their -webkit- twins), which is why the reveal stays cheap at 60fps: the browser keeps one mask image and re-rasterises its box.
Accessibility
- Reduced motion:
prefers-reduced-motion: reducedrops the masking entirely, in CSS and again in the script'smatchMediabranch. Both compositions revert to stacked blocks in normal flow, so every line of copy is legible, and the toggle button hides because there is nothing left to reveal. - Keyboard: the
Reveal everythingbutton is a real<button>with a maintainedaria-pressedstate, visible to everyone rather than hidden as an accessibility afterthought. It opens the aperture over the whole composition and closes it again. - Assistive tech: the hidden layer is masked, never
display: none,visibility: hidden, oraria-hidden, so its text stays in the accessibility tree and in the document for search engines. Both compositions are read in DOM order. - Touch: there is no cursor to chase, so the aperture drifts along a slow autonomous path and a tap springs it to the tapped point with an elastic settle. Both layers stay discoverable on a phone.
- No JavaScript, no mask support: the layers stay in normal flow as two stacked, readable blocks. Nothing is ever left at
opacity: 0.
Performance notes
- The aperture is driven from a single
gsap.tickercallback that writes four style properties per container per frame and reads no layout. Container geometry is cached and re-measured only on resize and scroll, both rAF-throttled. will-change: transformis set on.csm-innerand.csm-rim. If you stack many of these on one page, drop it from.csm-innerfirst.- Every listener is removed and every tween killed when the breakpoint changes or the page unloads.
Dependencies
Required:
- GSAP 3.15.0 (core only)
Not used: ScrollTrigger, Lenis, or any other plugin.
Masking needs mask-image support (all current browsers; Safari is covered by the -webkit- prefixed properties, which ship alongside every write). The script feature-detects it and falls back to the stacked layout where it is missing.
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.