Blind Embossed Paragraph That Inks In Under a Passing Lamp

A sheet of cotton paper lies on a dark desk with one paragraph pressed into it and no ink, so each word is only a dent you can read by its shadow. A lamp crosses the sheet and the words it reaches fill with ink and stay filled. Every word carries two text shadows, a dark one on the wall nearest the lamp and a pale one on the far wall, and both swing round as the lamp moves, which is what makes the letters look sunk into the paper instead of drawn on it. Move a pointer or a finger over the sheet and the lamp follows it. On a phone the lamp travels down the page instead of across, because the paragraph is taller than it is wide there.

Code

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

// The paragraph. Edit the sentence here; it is split into words for you.
const TEXT =
  "A good page is pressed, not printed. The type bites into cotton paper and leaves a small valley at every stroke, and you only see it when the light comes in low from the side.";
const COLOPHON = "Set by hand, proof no. 14";

const PAPER = [236, 229, 214]; // floor of a blind (un-inked) letter where the lamp is brightest
const PAPER_SHADE = [190, 168, 140]; // the same floor out in the unlit part of the sheet
const INK = [38, 27, 22]; // colour of a fully inked letter
const PASS_SECONDS = 3.4; // how long the lamp takes to cross the sheet
const HOLD_SECONDS = 2.6; // how long the inked page rests before it starts again

const GRAIN =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' seed='7'/%3E%3CfeColorMatrix values='0 0 0 0 .25 0 0 0 0 .18 0 0 0 0 .1 0 0 0 .5 0'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)'/%3E%3C/svg%3E\")";

type Box = { x: number; y: number };

export default function BlindEmbossedParagraph() {
  const reduce = useReducedMotion();
  const sheetRef = useRef<HTMLDivElement>(null);
  const lampRef = useRef<HTMLDivElement>(null);
  const shadeRef = useRef<HTMLDivElement>(null);
  const wordRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const [run, setRun] = useState(0);
  const words = TEXT.split(" ");
  const all = [...words, COLOPHON];

  useEffect(() => {
    const sheet = sheetRef.current;
    if (!sheet) return;
    const els = wordRefs.current.filter(Boolean) as HTMLSpanElement[];
    const ink = els.map(() => (reduce ? 1 : 0));
    let boxes: Box[] = [];
    let W = 1;
    let H = 1;

    const measure = () => {
      const r = sheet.getBoundingClientRect();
      W = r.width;
      H = r.height;
      boxes = els.map((el) => {
        const b = el.getBoundingClientRect();
        return { x: b.left - r.left + b.width / 2, y: b.top - r.top + b.height / 2 };
      });
    };

    // Paints every word for a lamp at (lx, ly): which way its bevel catches light, and how inked it is.
    const paint = (lx: number, ly: number, power: number) => {
      const R = Math.max(170, Math.min(W, H) * 0.62);
      for (let i = 0; i < els.length; i++) {
        const b = boxes[i];
        if (!b) continue;
        const dx = b.x - lx;
        const dy = b.y - ly;
        const d = Math.hypot(dx, dy) || 1;
        const ux = dx / d;
        const uy = dy / d;
        const lit = Math.exp(-(d * d) / (R * R)) * power;
        const k = ink[i];
        const tone = Math.pow(Math.min(1, lit * 1.1), 1.4);
        const c = PAPER.map((p, j) => {
          const floor = (PAPER_SHADE[j] + (p - PAPER_SHADE[j]) * tone) * 0.975;
          return Math.round(floor + (INK[j] - floor) * k);
        });
        // A pressed letter: the wall nearest the lamp falls into shadow, the far wall catches the light.
        const reach = 0.55 + Math.min(0.85, d / 420);
        const dark = (0.3 + 0.38 * lit) * (1 - 0.6 * k);
        const glint = (0.2 + 0.6 * lit) * (1 - 0.78 * k);
        const s = els[i].style;
        s.color = `rgb(${c[0]},${c[1]},${c[2]})`;
        s.textShadow =
          `${(-ux * reach).toFixed(2)}px ${(-uy * reach).toFixed(2)}px ${(0.25 + reach * 0.35).toFixed(2)}px rgba(52,36,20,${dark.toFixed(3)}),` +
          `${(ux * 0.8).toFixed(2)}px ${(uy * 0.8).toFixed(2)}px 0.3px rgba(255,252,243,${glint.toFixed(3)})`;
      }
      if (lampRef.current) lampRef.current.style.transform = `translate3d(${lx}px, ${ly}px, 0)`;
      if (shadeRef.current) shadeRef.current.style.transform = `translate3d(${lx}px, ${ly}px, 0)`;
    };

    measure();
    const ro = new ResizeObserver(() => {
      measure();
      if (reduce) paint(W * 0.3, -H * 0.1, 0.9);
    });
    ro.observe(sheet);

    if (reduce) {
      paint(W * 0.3, -H * 0.1, 0.9);
      return () => ro.disconnect();
    }

    let raf = 0;
    let visible = true;
    let last = performance.now();
    let clock = 0; // seconds into the current cycle
    let lx = -200;
    let ly = 0;
    let first = true;
    const pointer = { on: false, x: 0, y: 0 };

    const frame = (now: number) => {
      raf = 0;
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      clock += dt;
      const total = PASS_SECONDS + HOLD_SECONDS + 1.1;
      if (clock > total) clock = 0;

      // The lamp's own route: along the long side of the sheet, a little off the diagonal.
      const p = Math.min(1, clock / PASS_SECONDS);
      const e = p * p * (3 - 2 * p);
      const wide = W >= H;
      const m = Math.max(W, H) * 0.34;
      const fading = clock > PASS_SECONDS + HOLD_SECONDS;
      // It comes to rest over the page, breathing a little, then glides back while the ink dries.
      const rest = clock > PASS_SECONDS ? Math.sin((clock - PASS_SECONDS) * 1.1) * 14 : 0;
      let tx = wide ? -m + (W * 0.68 + m) * e + rest : W * (0.25 + 0.3 * e);
      let ty = wide ? H * (0.16 + 0.22 * e) : -m + (H * 0.42 + m) * e + rest;
      if (fading) {
        tx = wide ? -m : W * 0.25;
        ty = wide ? H * 0.16 : -m;
      }
      if (pointer.on) {
        tx = pointer.x;
        ty = pointer.y;
      }
      if (first) {
        lx = tx;
        ly = ty;
        first = false;
      }
      const follow = 1 - Math.exp(-dt * (pointer.on ? 9 : fading ? 3.5 : 14));
      lx += (tx - lx) * follow;
      ly += (ty - ly) * follow;

      // Ink develops where the light has rested, and dries back to blind at the end of the cycle.
      const R = Math.max(170, Math.min(W, H) * 0.62);
      for (let i = 0; i < ink.length; i++) {
        const b = boxes[i];
        if (!b) continue;
        if (fading && !pointer.on) {
          ink[i] = Math.max(0, ink[i] - dt * 1.4);
          continue;
        }
        const d = Math.hypot(b.x - lx, b.y - ly);
        const lit = Math.exp(-(d * d) / (R * R));
        if (lit > 0.42) ink[i] = Math.min(1, ink[i] + dt * 3.2 * (lit - 0.3));
        else if (!pointer.on && clock > PASS_SECONDS) ink[i] = Math.min(1, ink[i] + dt * 2);
      }
      paint(lx, ly, 1);
      if (visible && !document.hidden) raf = requestAnimationFrame(frame);
    };
    const start = () => {
      if (!raf && visible && !document.hidden) {
        last = performance.now();
        raf = requestAnimationFrame(frame);
      }
    };
    const io = new IntersectionObserver(([en]) => {
      visible = en.isIntersecting;
      start();
    });
    io.observe(sheet);
    const onVis = () => start();
    document.addEventListener("visibilitychange", onVis);

    const move = (ev: PointerEvent) => {
      const r = sheet.getBoundingClientRect();
      pointer.on = true;
      pointer.x = ev.clientX - r.left;
      pointer.y = ev.clientY - r.top;
    };
    const leave = () => {
      pointer.on = false;
    };
    sheet.addEventListener("pointermove", move);
    sheet.addEventListener("pointerdown", move);
    sheet.addEventListener("pointerleave", leave);
    sheet.addEventListener("pointercancel", leave);
    start();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      sheet.removeEventListener("pointermove", move);
      sheet.removeEventListener("pointerdown", move);
      sheet.removeEventListener("pointerleave", leave);
      sheet.removeEventListener("pointercancel", leave);
    };
  }, [reduce, run]);

  return (
    <section className="relative flex min-h-[640px] w-full items-center justify-center overflow-hidden bg-[#161310] px-4 py-14 sm:px-8 sm:py-20">
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{ background: "radial-gradient(ellipse at 50% 42%, rgba(96,74,48,0.38), rgba(22,19,16,0) 62%)" }}
      />
      <div className="relative w-full max-w-[820px]">
        <div
          ref={sheetRef}
          className="relative overflow-hidden rounded-[2px] bg-[#efe8d8] px-7 py-12 shadow-[0_1px_0_rgba(255,255,255,0.55)_inset,0_-1px_0_rgba(90,70,45,0.35)_inset,0_2px_0_#b9ad97,0_3px_0_#8f8470,0_34px_60px_-16px_rgba(0,0,0,0.8),0_10px_18px_-8px_rgba(0,0,0,0.6)] [touch-action:pan-y] sm:px-16 sm:py-20"
        >
          {/* paper tooth */}
          <div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-[0.5] mix-blend-multiply" style={{ backgroundImage: GRAIN }} />
          {/* the room: everything the lamp does not reach sits in warm shade */}
          <div
            ref={shadeRef}
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 h-0 w-0 will-change-transform"
            style={{ transform: "translate3d(30%, -10%, 0)" }}
          >
            <div
              className="absolute -left-[1600px] -top-[1600px] h-[3200px] w-[3200px] mix-blend-multiply"
              style={{
                background:
                  "radial-gradient(circle closest-side, rgba(150,108,64,0) 0%, rgba(150,108,64,0) 5%, rgba(150,108,64,0.2) 13%, rgba(150,108,64,0.4) 24%, rgba(140,98,58,0.5) 100%)",
              }}
            />
          </div>
          {/* the lamp's warm pool */}
          <div ref={lampRef} aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-0 w-0 will-change-transform">
            <div
              className="absolute -left-[340px] -top-[340px] h-[680px] w-[680px]"
              style={{ background: "radial-gradient(circle closest-side, rgba(255,244,214,0.5), rgba(255,236,196,0.16) 45%, rgba(255,236,196,0) 100%)" }}
            />
          </div>

          <p className="relative select-none text-balance font-serif text-[27px] font-medium leading-[1.32] tracking-[-0.012em] text-[#ece5d6] sm:text-[42px] sm:leading-[1.24]">
            {words.map((w, i) => (
              <span key={i}>
                <span
                  ref={(el) => {
                    wordRefs.current[i] = el;
                  }}
                  className="inline-block"
                  style={reduce ? { color: "rgb(38,27,22)" } : undefined}
                >
                  {w}
                </span>
                {i < words.length - 1 ? " " : ""}
              </span>
            ))}
          </p>
          <div className="relative mt-10 flex items-center gap-4 sm:mt-14">
            <span aria-hidden="true" className="h-px w-6 shrink-0 bg-[#261b16]/25 sm:w-10" />
            <span
              ref={(el) => {
                wordRefs.current[all.length - 1] = el;
              }}
              className="whitespace-nowrap font-serif text-[10.5px] uppercase tracking-[0.2em] text-[#e9e2d3] sm:text-[13px] sm:tracking-[0.28em]"
              style={reduce ? { color: "rgb(38,27,22)" } : undefined}
            >
              {COLOPHON}
            </span>
          </div>
        </div>

        <div className="mt-6 flex items-center justify-between gap-4 px-1">
          <p className="text-[12px] tracking-wide text-[#a89a86]">Move a light over the page.</p>
          <button
            type="button"
            onClick={() => setRun((n) => n + 1)}
            className="inline-flex h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#4a4034] px-4 text-[12px] font-medium tracking-wide text-[#e6dccb] transition-colors hover:bg-[#241f1a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#e6dccb] focus-visible:ring-offset-2 focus-visible:ring-offset-[#161310]"
          >
            <svg aria-hidden="true" viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M4 10a6 6 0 1 0 2-4.5" />
              <path d="M4 4v3.2h3.2" />
            </svg>
            Pass the lamp again
          </button>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/blind-embossed-paragraph-that-inks-in-under-a-passing-lamp.json

When to use it

How to customize it

Accessibility

Questions

What is blind embossing?

It is pressing a shape into paper without any ink or foil, so the only thing that shows it is the way light falls across the dent. Printers call the sunk version debossing. This component imitates it with a pair of text shadows on letters that are the same colour as the sheet.

How do you make text look pressed into a surface with CSS?

Give the letters the colour of the surface, then add a dark `text-shadow` offset toward the light source and a pale one offset away from it. The dark shadow reads as the near wall of the dent and the pale one as the lit far wall. Swap the two and the same text looks raised.

Why does each word get its own shadow instead of the whole paragraph?

A lamp close to the page lights each word from a different direction. Words to its left throw their shadow one way and words to its right the other. Splitting the paragraph into word spans lets the script work out that direction per word, and it is that fan of angles that sells the lamp as being near.

Is animating text-shadow slow?

It repaints the text, so it costs more than a transform. Here it touches about forty short spans inside one sheet, the loop stops when the sheet scrolls out of view or the tab is hidden, and the light itself is moved with a transform. For a full article, light only the opening paragraph.

Can the words stay inked instead of starting again?

Yes. The cycle resets when `clock` passes `total`. Remove that reset and the fading branch, and the page will ink once and then only respond to the pointer.

Related: Cinema Marquee Headline with Letter Tiles That Slide Along Rails and Chasing Bulbs, Finger-Written Words in Window Condensation That Drip, Fog Over and Wipe Clear, Hand-Gilded Shop Fascia Headline Where Gold Leaf Is Laid, Brushed Off and Burnished.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap