CSS + JS Text effects #text #pointer #blend

Anaglyph 3D text in CSS + JavaScript

A red and a cyan copy of one word, blended with screen. The pointer sets how far apart they sit and turns the card, like old red/cyan cinema glasses.

  • 70 lines of CSS
  • 9 lines of HTML
  • 30 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Two copies of the word sit in the same spot, one red and one cyan, each shifted apart with translate() and blended with mix-blend-mode: screen — like old red/cyan 3D glasses, where the overlap reads near-white and the fringes stay coloured.
  2. The card itself is a single flat plane that only rotates (rotateY / rotateX); it is deliberately not preserve-3d, because a blend mode only combines elements painted into the same flat surface.
  3. JS reports one thing — the pointer position over the stage — and writes it into four custom properties (--sx, --sy, --ry, --rx); every animated value in the CSS just reads one of them. The shifts are plain numbers, not lengths: CSS multiplies them by --u, the one base unit every length here is a multiple of, so the card is the same share of a gallery card, the editor and a recording canvas.
  4. While the pointer is over the card the transition is fast, 0.12s with ease-out (.is-live); once it leaves, the slower springy transition takes over for the way back to rest.

Key techniques

  • mix-blend-mode: screen on the leaves
  • pointer → --sx / --ry / --rx
  • flat card, no preserve-3d around a blend
  • fast transition live, springy on release

Copy-paste code

HTML 9 lines

<div class="scene" tabindex="0" role="img" aria-label="STEREO in red and cyan layers that shift apart with the pointer">
  <div class="anaglyph">
    <div class="word">
      <span>STEREO</span>
      <span aria-hidden="true">STEREO</span>
    </div>
    <small>move your pointer</small>
  </div>
</div>

CSS 70 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the card is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.41vmin;
  perspective: calc(800 * var(--u));
}

.anaglyph {
  box-sizing: content-box; /* its padding and edge are round the words */
  display: grid;
  place-items: center;
  gap: calc(2 * var(--u));
  width: calc(214 * var(--u));
  padding: calc(24 * var(--u)) 0 calc(16 * var(--u));
  border: calc(1 * var(--u)) solid #3a4070;
  border-radius: calc(16 * var(--u));
  background: radial-gradient(circle at 50% 30%, #171b38, #07080f 75%);
  box-shadow: 0 calc(22 * var(--u)) calc(34 * var(--u)) calc(-22 * var(--u)) rgb(0 0 0 / 0.85);
  /* the card turns as ONE flat plane in the stage's perspective; not preserve-3d, because the
     screen blend below only works among elements painted into the same flat surface */
  transform: rotateY(var(--ry, -14deg)) rotateX(var(--rx, 6deg));
  transition: transform 0.7s cubic-bezier(0.3, 1.3, 0.5, 1);
}

.word {
  position: relative;
  font-size: calc(43 * var(--u));
  font-weight: 900;
  line-height: 1;
  letter-spacing: 0.02em;
  user-select: none;
}

/* two copies of the word, one per eye, pushed apart horizontally; screen adds their light,
   so where red and cyan overlap you get near-white and the fringes stay coloured */
.word span {
  display: block;
  color: #ff1744;
  mix-blend-mode: screen;
  /* --sx / --sy are plain numbers from JS; the unit is the model's, like every other length */
  transform: translate(calc(var(--sx, 3) * -1 * var(--u)), calc(var(--sy, 0) * -1 * var(--u)));
  transition: transform 0.7s cubic-bezier(0.3, 1.3, 0.5, 1);
}

.word span + span {
  position: absolute;
  inset: 0;
  color: #00e5ff;
  transform: translate(calc(var(--sx, 3) * var(--u)), calc(var(--sy, 0) * var(--u)));
}

.anaglyph small {
  color: #949bc0;
  font-size: calc(9.5 * var(--u));
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

/* while the pointer is over the stage, follow it almost directly; the slow springy transition
   above is only used on the way back */
.anaglyph.is-live,
.anaglyph.is-live span {
  transition-duration: 0.12s;
  transition-timing-function: ease-out;
}

/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
   the pointer resting on the canvas (!important beats the pose the script writes inline) */
.scene:focus-visible .anaglyph { --rx: -4deg !important; --ry: 12deg !important; --sx: -4 !important; --sy: 1 !important; }

JS 30 lines

var stage = document.querySelector('.scene');
var card = document.querySelector('.anaglyph');

function clamp(v, min, max) {
  return Math.min(max, Math.max(min, v));
}

function move(e) {
  var r = stage.getBoundingClientRect();
  var x = clamp((e.clientX - r.left) / r.width - 0.5, -0.5, 0.5);
  var y = clamp((e.clientY - r.top) / r.height - 0.5, -0.5, 0.5);
  // plain numbers for the shift: CSS multiplies them by --u, so it scales with the canvas
  card.style.setProperty('--sx', (x * 16).toFixed(2));
  card.style.setProperty('--sy', (y * 5).toFixed(2));
  card.style.setProperty('--ry', (x * 44).toFixed(1) + 'deg');
  card.style.setProperty('--rx', (-y * 30).toFixed(1) + 'deg');
  card.classList.add('is-live');
}

function leave() {
  card.classList.remove('is-live');
  ['--sx', '--sy', '--ry', '--rx'].forEach(function (p) {
    card.style.removeProperty(p);
  });
}

stage.addEventListener('pointermove', move);
stage.addEventListener('pointerdown', move);
stage.addEventListener('pointerleave', leave);
stage.addEventListener('pointercancel', leave);
Standalone snippet · plain CSS, no build step, no dependencies.