Prismatic Refraction Card
A 340 px glass pane over four blurred color blobs: backdrop-filter blur 18 px + saturate 250%, two red and cyan borders offset up to ±4.2 px with the pointer (the chromatic aberration), a rainbow band translated up to ±27% and a ±10° / ±7° tilt, all smoothed by a rAF loop that stops at rest and wakes on pointer or keyboard. Measured: the white text drops to 1.4–3.9:1 on the glass.
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. Cards has 41 effects, including 6 free. Explore the category →
3 usage examples



How it works
The scene: .prc-scene (width: 100%; aspect-ratio: 16 / 10, border-radius: 14px; overflow: hidden; isolation: isolate; perspective: 1000px, #0b1020 background, crosshair cursor) holds .prc-bg (inset: -14%) and its four .prc-blobs — discs 46% wide (#ff2050, #2855ff, #00d4aa, #bf38ff), filter: blur(34px), opacity .88, animated by prc-float over 20 s (±10% translation, scale .93 to 1.10, offsets 0 / −6 / −13 / −9 s). They exist so the glass has something to refract. .prc-card-wrap (min(72%, 340px), will-change: transform) receives the tilt and contains .prc-ca then .prc-glass.
The glass: background: linear-gradient(135deg, rgba(255,255,255,.16), .05), 1 px white border at 22%, four shadows (0 16px 56px .40, a light inner hairline inset 0 1px 1px .56, an inner penumbra inset 0 -2px 10px .20 and a green halo 0 0 3px rgba(0,255,130,.16)), and backdrop-filter: blur(18px) saturate(250%) brightness(1.06), prefixed -webkit-. ::before = static bevel (128° gradient, white .26 → transparent at 65%); ::after = specular radial-gradient(115px 95px at var(--mx) var(--my), white .40 → .08 → transparent) in screen. .prc-prism (210% × 210%, top/left: -55%) is a 108° gradient of seven colored bands at 14–22% alpha between 26 and 70% of its width, in screen, opacity --prc-prism-opacity (.78); the glass's overflow: hidden shows only a window of it, which the JS moves.
The chromatic aberration: .prc-ca (inset: 0; border-radius: 28px) carries ::before, a 1.8 px rgba(255,32,62,.82) border with a glow, translated by (--prc-ca-x, --prc-ca-y), and ::after, the same in cyan rgba(0,210,255,.82) translated the opposite way (calc(var() * -1)). Both have transition: transform .14s ease-out, on top of the JS smoothing. The wrapper receives the variables: cax = (lx − .5) × 8.4 → ±4.2 px, cay = (ly − .5) × 4.2 → ±2.1 px. The IDLE rest position (.66, .34) is off-center: without interaction, the fringes are already offset by +1.34 / −0.67 px and the card tilted 3.2° / 2.2°.
The script reads four ids (prcScene, prcWrap, prcGlass, prcPrism) and bails out if one is missing — a single card per page, plus an optional second set of ids (…Ex). pointermove on the scene normalizes the position (0 to 1, clamped); tick() moves 7.2% of the way per frame (95% in ≈ 40 frames, ≈ 670 ms at 60 Hz) and calls applyCard: rotateX((.5 − ly) × 14) rotateY((lx − .5) × 20) on the wrapper, the fringe variables, --mx/--my in % on the glass, translate((lx − .5) × 54%, (ly − .5) × 54%) on the band. tick() only reschedules itself while the gap between target and smoothed value exceeds .0005 on either scene; otherwise the loop stops (rafId = 0) and reveiller() restarts it on pointermove, pointerleave and every arrow key — at rest, no rAF call. On the keyboard, arrows move the target by .16, Escape and Home recenter it. No touchmove listener: pointermove already covers touch, and page scrolling stays free. The file also ships nine getElementById calls for review sliders (prcIntensity, prcTilt, prcA11y…) and ~105 lines of CSS for a pricing card (.prc-glass--pricing, .prc-price-*, .prc-features, .prc-cta) that no sold HTML uses.
Accessibility
- Insufficient contrast as shipped — measured in Chromium (text rendered transparent, pixels sampled under the text, three pointer positions and three moments of the blob cycle): title
#fff20 px bold, minimum 1.35:1, median 2.6 to 3.05:1, maximum 3.9:1 — below the 3:1 large-text threshold on most pixels; bodyrgba(255,255,255,.88)13.5 px, minimum 1.21:1, median ≈ 2.5:1 (4.5:1 threshold). Thetext-shadow: 0 1px 12px rgba(0,0,0,.55)doesn't change the minimum (1.57). Fix computed on the lightest measured pixel (rgb(26,249,218)):.prc-content { background: rgba(8,10,28,.6); padding: 14px 16px; border-radius: 14px }→ title 6.4:1, body 5.4:1 (at .55 alpha: 5.3 / 4.5). Alternative: darken the glass itself (brightness(.7),rgba(8,10,28,.5)background). - Reduced motion: the blobs freeze, the fringe transitions go, tilt and band are disabled; but
LERPbecomes 1 and the specular still follows the pointer — no more animation, but still motion. Reduced transparency: opaque#13162eglass, band hidden — the text then reaches 17.8:1. - Roles:
.prc-glasshasrole="img",tabindex="0"and reacts to the arrow keys — an image isn't interactive: screen readers announce an image and the keyboard hint in the label (shipped in French) means nothing to them. Userole="group"with the label, or droptabindexif keyboard driving isn't wanted. - Touch: no
touchmovelistener, the page scrolls normally when the finger crosses the card; Pointer Events (pointermove) drive the effect with a finger, and the scene iswidth: 100%— the card spans the phone's full width. If you would rather have the finger drive the card without scrolling the page, settouch-action: noneon.prc-scene. - Focus:
.prc-glass:focus-visibledrawsoutline: 3px solid rgba(129,140,248,.88)at 4 px. Performance: four 34 px-blurred blobs animating permanently and abackdrop-filterrecomputed every frame — heavy on a modest GPU, even with the rAF loop stopped (it only runs during the smoothing, about 80 to 100 frames after the last gesture — computed, ≈ 1.5 s at 60 Hz). To lighten it, reduce the blobs' blur or freeze them outside hover.
Browser compatibility
Requires backdrop-filter (-webkit- prefix provided), aspect-ratio, inset, mix-blend-mode, calc() on variables and Pointer Events. No library.
Without backdrop-filter (Firefox before 103), the glass is just a white gradient at 16 → 5%: the blobs stay sharp behind it, the glass effect is gone, fringes and rainbow band remain. Without JavaScript, everything stays at the CSS variables' rest position (fringes offset 2.5 / 0.8 px, specular at 32% / 22%), nothing follows the pointer; the blobs keep floating.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="prc-scene" id="prcScene">
<div class="prc-bg" aria-hidden="true">
<span class="prc-blob prc-blob--1"></span>
<span class="prc-blob prc-blob--2"></span>
<span class="prc-blob prc-blob--3"></span>
<span class="prc-blob prc-blob--4"></span>
</div>
<div class="prc-card-wrap" id="prcWrap">
<div class="prc-ca" aria-hidden="true"></div>
<div class="prc-glass" id="prcGlass" tabindex="0" role="img" aria-label="Glass card with prismatic dispersion — move the mouse or use the arrow keys">
<div class="prc-prism" aria-hidden="true" id="prcPrism"></div>
<div class="prc-content">
<h3 class="prc-title">Prismatic Glass</h3>
<p class="prc-body">The edges split light into red and cyan. Move the cursor to sweep the rainbow.</p>
</div>
</div>
</div>
</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 |
|---|---|---|
| CA_SCALE (JS) | 4.2 | Maximum fringe offset: ±4.2 px in x (× 2), ±2.1 px in y. 8 = heavy prism; 1.5 = subtle. The two channels move apart symmetrically. |
| TILT_X / TILT_Y (JS) | 14 / 20 | Tilt amplitude in degrees (±7° / ±10°). TILT_MULT is a multiplier left over for a review slider — useless in production. |
| LERP (JS) | 0.072 | Fraction of the remaining distance per frame. .15 = responsive (~300 ms); .04 = slow (1.2 s). Under prefers-reduced-motion it is 1 (instant). |
| IDLE_X / IDLE_Y (JS) | .66 / .34 | Rest position (0 to 1): fringes and specular already offset without interaction, card tilted 3.2° / 2.2°. .5 / .5 = card perfectly face-on at rest. |
Prismatic band (CSS — .prc-prism, --prc-prism-opacity) |
seven bands, opacity .78 | Colors at 14–22% alpha between 26 and 70% of the band's width; JS travel ±27% ((lx − .5) × 54). .4 for a discreet rainbow; 0 removes it without touching the HTML. |
| Glass (CSS — .prc-glass backdrop-filter) | blur(18px) saturate(250%) brightness(1.06) | The 250% saturate makes the blobs blaze behind the glass — it's what crushes the contrast. saturate(140%) brightness(.8) gives smoked, more readable glass. |
| Blobs (CSS — .prc-blob: colors, blur 34px, opacity .88, 20 s) | #ff2050, #2855ff, #00d4aa, #bf38ff | The light the glass refracts. Swap in your brand colors; on an already colored page background, remove .prc-bg — the glass then filters whatever is behind it. |
FAQ
prcScene, prcWrap, prcGlass, prcPrism) and provides a second hard-coded set (prcSceneEx, prcWrapEx, prcGlassEx, prcPrismEx) — the catalog page's usage example. For N cards, replace the getElementById calls with document.querySelectorAll('.prc-scene').forEach(init) using scene.querySelector('.prc-card-wrap') etc., and one state (mx, my, tx, ty) per scene.saturate(250%), which makes the cyan and pink blobs very bright behind it. The least visible remedy is a scrim under the content alone, rgba(8,10,28,.6): title 6.4:1 and body 5.4:1 on the lightest measured pixel. Otherwise, bring the saturate down to 140% and the brightness to .8.tick() loop stops on its own: it only reschedules itself while the gap between the target and the smoothed value exceeds .0005 (on the main card or on the example), then rafId goes back to 0 and reveiller() restarts it on the next pointermove, pointerleave or arrow key — at rest, no requestAnimationFrame call. What keeps running: the four blurred blobs, animated in CSS over 20 s, and the glass's backdrop-filter, recomputed on every frame where something moves behind it. Under prefers-reduced-motion, the blobs freeze (animation: none) and the loop, with LERP at 1, stops on the very next frame.