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
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.
  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.