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.

  • 61 lines of CSS
  • 9 lines of HTML
  • 29 lines of JS
  • No dependencies
  • MIT licensed

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.
  4. While the pointer is over the card the transition is fast and linear (.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">
  <div class="anaglyph">
    <div class="word">
      <span>STEREO</span>
      <span aria-hidden="true">STEREO</span>
    </div>
    <small>move your pointer</small>
  </div>
</div>

CSS 61 lines

.scene {
  perspective: 800px;
}

.anaglyph {
  display: grid;
  place-items: center;
  gap: 2px;
  width: 214px;
  padding: 24px 0 16px;
  border: 1px solid #3a4070;
  border-radius: 16px;
  background: radial-gradient(circle at 50% 30%, #171b38, #07080f 75%);
  box-shadow: 0 22px 34px -22px 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: 43px;
  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;
  transform: translate(calc(var(--sx, 3px) * -1), calc(var(--sy, 0px) * -1));
  transition: transform 0.7s cubic-bezier(0.3, 1.3, 0.5, 1);
}

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

.anaglyph small {
  color: #949bc0;
  font-size: 9.5px;
  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;
}

JS 29 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);
  card.style.setProperty('--sx', (x * 16).toFixed(2) + 'px');
  card.style.setProperty('--sy', (y * 5).toFixed(2) + 'px');
  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);

Sass source 59 lines

// Red/cyan stereo text. JS writes three numbers on the card: --sx (eye separation), --ry, --rx.
// The card is always dark, because `screen` blending needs a dark backdrop to add light to.
.d-anaglyph {
  display: grid;
  place-items: center;
  gap: 2px;
  width: 214px;
  padding: 24px 0 16px;
  border: 1px solid var(--border-strong);
  border-radius: 16px;
  background: radial-gradient(circle at 50% 30%, #171b38, #07080f 75%);
  box-shadow: 0 22px 34px -22px rgb(0 0 0 / 0.85);
  // The card turns as ONE flat plane in the stage's perspective. It is deliberately not
  // preserve-3d: blending 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: 43px;
    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: where red and cyan overlap you get near-white, the fringes stay coloured.
    span {
      display: block;
      color: #ff1744;
      mix-blend-mode: screen; // on the leaves only, never on a 3D container
      transform: translate(calc(var(--sx, 3px) * -1), calc(var(--sy, 0px) * -1));
      transition: transform 0.7s cubic-bezier(0.3, 1.3, 0.5, 1);
    }

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

  small {
    color: #949bc0;
    font-size: 9.5px;
    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 for the way back.
  &.is-live,
  &.is-live span {
    transition-duration: 0.12s;
    transition-timing-function: ease-out;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.