← Text effects

Letterpress Press

A raised plate of type drops onto the page, thuds in and lifts away, leaving the words pressed into the paper: shaded along their top edges, with the light catching the paper just below, like a letterpress print.

Pressed in

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-letterpress-press to a heading on a light, paper-coloured background.

Options

Add any of these attributes to the same element.

data-inkInk colour. Default #3d2c5a.
data-depthHow deep the impression looks: 1.5 is half as deep again. Default 1.
data-loopFade the print away and press it again, forever.
data-holdHow long the print stays before fading when looping. Default 2400 ms.
data-delayWait before the first press. Default 200 ms.

Click or tap the text to press it again. The inset look needs a light background: on a dark one, use a light ink and a smaller data-depth.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Letterpress Press: a raised plate of type comes down onto the page,
    // presses in with a small thud and lifts away, leaving the words debossed
    // into the paper with an inset shadow, like letterpress printing. Click or
    // tap to press it again.
    // Usage: <h1 data-letterpress-press>Pressed in</h1>
    // Optional: data-ink (ink colour; default #3d2c5a), data-depth (how deep
    // the impression looks, 1 = normal; default 1), data-loop (fade out and
    // press again, forever), data-hold (ms the impression stays when looping;
    // default 2400), data-delay (ms before the first press; default 200).
    var DEFAULTS = { ink: '#3d2c5a', depth: 1, hold: 2400, delay: 200 };

    var css =
      '.lpp-wrap{position:relative;display:inline-block}' +
      '.lpp-ink{display:block;color:transparent;-webkit-text-fill-color:transparent;-webkit-background-clip:text;background-clip:text}' +
      '.lpp-plate{position:absolute;inset:0;pointer-events:none;opacity:0;transform-origin:50% 50%;will-change:transform,opacity}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var ink = d.ink || DEFAULTS.ink;
      var depth = d.depth !== undefined ? +d.depth : DEFAULTS.depth;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var hold = +d.hold || DEFAULTS.hold;
      var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;

      var wrap = document.createElement('span');
      wrap.className = 'lpp-wrap';
      var impression = document.createElement('span');
      impression.className = 'lpp-ink';
      while (el.firstChild) impression.appendChild(el.firstChild);
      impression.style.backgroundColor = ink;
      var plate = document.createElement('span');
      plate.className = 'lpp-plate';
      plate.setAttribute('aria-hidden', 'true');
      plate.textContent = impression.textContent;
      plate.style.color = ink;
      wrap.appendChild(impression);
      wrap.appendChild(plate);
      el.appendChild(wrap);

      // s: 0 = flat ink, 1 = fully pressed in. The light falls from above, so
      // the top edge of each letter is in shade and the paper below it catches light.
      var st = { s: 1, ink: 1, lift: 1 };
      function drawImpression() {
        var k = st.s * depth;
        impression.style.textShadow =
          '0 ' + (0.035 * k).toFixed(4) + 'em ' + (0.025 * k).toFixed(4) + 'em rgba(255,255,255,' + (0.38 * Math.min(1, k)).toFixed(3) + '),' +
          '0 ' + (-0.012 * k).toFixed(4) + 'em ' + (0.01 * k).toFixed(4) + 'em rgba(0,0,0,' + (0.22 * Math.min(1, k)).toFixed(3) + ')';
        impression.style.opacity = st.ink.toFixed(3);
      }
      // lift: 1 = hovering high above the page, 0 = touching it.
      function drawPlate() {
        var h = st.lift;
        plate.style.transform = 'translateY(' + (-0.12 * h).toFixed(4) + 'em) scale(' + (1 + 0.12 * h).toFixed(4) + ')';
        plate.style.textShadow = '0 ' + (0.02 + 0.3 * h).toFixed(4) + 'em ' + (0.02 + 0.25 * h).toFixed(4) + 'em rgba(20,10,30,' + (0.4 - 0.22 * h).toFixed(3) + ')';
      }
      drawImpression();
      if (reduced()) return;

      var running = false, visible = false, waiting = false;
      function press() {
        running = true;
        st.s = 0; st.ink = 0; st.lift = 1;
        drawImpression(); drawPlate();
        var tl = anime.createTimeline({ onComplete: after });
        // The plate falls onto the page...
        tl.add(plate, { opacity: [0, 1], duration: 260, ease: 'out(2)' }, 0)
          .add(st, { lift: 0, duration: 620, ease: 'in(3)', onUpdate: drawPlate }, 0)
          // ...thuds in, the paper giving a little...
          .add(wrap, { scale: [{ to: 0.975, duration: 90, ease: 'out(2)' }, { to: 1, duration: 420, ease: 'outElastic(1, .5)' }] }, 620)
          .add(st, { ink: 1, duration: 60, ease: 'linear', onUpdate: drawImpression }, 620)
          .add(st, { s: 1, duration: 520, ease: 'out(3)', onUpdate: drawImpression }, 640)
          // ...and lifts away, leaving the impression.
          .add(st, { lift: 0.7, duration: 560, ease: 'out(2)', onUpdate: drawPlate }, 760)
          .add(plate, { opacity: 0, duration: 420, ease: 'out(2)' }, 760);
      }
      function after() {
        if (!loop) { running = false; return; }
        setTimeout(function () {
          // The page is swapped for a fresh sheet.
          anime.animate(st, {
            ink: 0, duration: 600, ease: 'inOut(2)', onUpdate: drawImpression,
            onComplete: function () {
              setTimeout(function () {
                running = false;
                if (visible) press(); else waiting = true;
              }, 350);
            }
          });
        }, hold);
      }

      el.addEventListener('pointerdown', function () { if (!running) press(); });

      st.ink = 0; drawImpression();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !running && (waiting || !el._lppPlayed)) {
          waiting = false;
          el._lppPlayed = true;
          running = true;
          setTimeout(press, delay);
        }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-letterpress-press]').forEach(function (el) {
        if (el._lpp) return;
        el._lpp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-letterpress-press>Pressed in</h1>

<!-- deep red ink, pressed harder, on a loop: -->
<h1 data-letterpress-press data-ink="#9b2c2c" data-depth="1.5" data-loop>Pressed in</h1>