Warped Text Wall

Seen on Nell · credit Nell
Open standalone ↗

How it works

This is the full-bleed hero on nell.ai — a wall of text you can drag sideways, readable along the top and dissolving into a smear as it falls. The React component is called NellShaderCanvas, which is a misnomer worth calling out: there is no WebGL anywhere in it. It is a plain 2D canvas painting monospace characters with batched fillText. Everything below was read out of their minified chunk (_next/static/chunks/8147-*.js), so this is a port of the real pipeline rather than a guess at the look.

The column wall. A ~700-word company text is split on whitespace into words. The code then builds an index list f of *mid-sentence entry points* — scanning from word 9 to length - 9, it keeps every position whose previous word does not end a sentence (/[.!?]["")\]]*$/). Those words are laid out into narrow vertical columns: pitch 294.21875px, column width 283.828125px, left pad 10.390625px, 28 columns per block, giving a block width of 8238.125px (294.21875 × 28, exactly). Column *n* takes the text rotated to begin at word f[(37n + 19) % f.length] — so every column opens mid-thought rather than on a tidy capital — concatenates that rotation to itself four times so it can never run dry, then greedy-word-wraps it to floor(283.828125 / charWidth) characters. One quirk is preserved faithfully: because the inner loop variable shadows the outer one in their minified code, column content repeats every 28 columns while column positions keep advancing — so extra blocks lengthen the strip without ever introducing new text.

The warp is a resample, not a transform. The wall is rendered as a character grid — 10px monospace, 13px line height, with charWidth measured once as measureText("M" × 20).width / 20. For every grid cell the renderer computes a displacement and then samples the character from the displaced position instead of the cell's own. Nothing is rotated, skewed, or scaled; each cell simply asks a different part of the text wall what letter it ought to be. That resampling through a flow field is the whole effect.

The field is classic 3D value noise: hash fract(sin(127.1x + 311.7y + 74.7z) * 43758.5453123) * 2 - 1, smoothstep-interpolated (t*t*(3-2t)) trilinearly across the lattice. Per cell, with o = max(rectWidth, rectHeight) and t in seconds, a swirl amount s = 1.2 + 2.9 * |noise3(x/o*3, y/o*3 - 0.1t, 0.03t)| warps the lookup coordinates into h = (x/o - 0.5) * s * 2 and u = (y/o - 0.5) * s - 0.08t at depth d = 0.1t, and the displacement is [620 * noise3(h + 31.7, u, d) * l, 780 * noise3(h, u + 47.3, d) * l]. Time enters only as the noise's third dimension (0.1t) plus that slow -0.08t vertical drift, so the field flows rather than jitters.

l is the falloff, and it is why the thing stays legible. It is anchored at the top of the canvas — their anchor term is always 0 — and computed as the squared normalized Y, so the first rows are essentially undisplaced and the smear grows quadratically as your eye travels down. Crisp text at the top, illegible pink weather at the bottom.

The call to action is stamped into the grid, not drawn over it. On the real site JOIN THE WAITLIST is written into the row nearest the visual-viewport centre; those cells always render the CTA character, and the only thing that animates is their colour. The reveal order is a hash shuffle — character indices sorted by fract(sin((i+1) * 92821.731) * 1e4) — each index flipping from ink to white once elapsed progress passes (order + 1) / count, across 3.2 seconds. The words don't type in; they precipitate out of the noise in a fixed, deterministic scatter.

Interaction is one number. Mouse wheel (whichever of deltaX/deltaY is larger in magnitude), pointer drag, and the Arrow keys (one column pitch per press) all feed a single target offset, clamped to [-(8238.125 - innerWidth + 294.21875), 0], which the renderer eases toward with current += (target - current) * 0.16. Pointer-down adds a grabbing-cursor class, and presses landing on links, buttons, inputs, or dialogs are excluded so the page stays usable. prefers-reduced-motion freezes time — it renders the finished 3.2s state and redraws only on interaction — and viewports <= 767px throttle to 24fps with devicePixelRatio capped at 1.5. For performance, runs of consecutive same-coloured characters are accumulated and flushed as a single fillText rather than one call per glyph. The palette comes from CSS custom properties: paper hsl(0 100% 18.2%) (deep oxblood), ink hsl(351.4 100% 65.7%) (hot pink-red), CTA hsl(0 0% 100%).

Two honest deviations. (1) The text here is placeholder lorem ipsum, not Nell's own company text. The effect is the layout-and-warp pipeline, not their marketing copy, and the text lives in a single clearly-marked constant you can swap for any long body of writing. (2) The CTA reads DRAG TO EXPLORE instead of JOIN THE WAITLIST, which doubles as a usage hint. Every geometry, noise, warp, reveal, easing, and clamp constant above is kept exactly as captured. Fully self-contained: no external scripts, fonts, or images — just the system monospace stack and one 2D canvas.

One further concession to living in a gallery: the original calls preventDefault on every wheel event, which costs it nothing on a page that never scrolls but would trap the host page's scroll inside an iframe. Here the wheel handler stands down when the file detects it is framed — drag and the arrow keys still move the strip.

Code — copy & paste this whole file

<!doctype html>
<!--
  Warped Text Wall  (nell.ai homepage background)
  ─────────────────────────────────────────────────────────────────────────────
  The full-bleed red field on https://nell.ai/ is a wall of prose resampled
  through a noise flow field. Their React component is named NellShaderCanvas,
  but it is not WebGL at all — it is a plain 2D canvas driven by batched
  fillText. This is a port of that pipeline, read out of their shipped chunk
  (_next/static/chunks/8147-*.js), not a guess at the look.

  How it works:

    1. A long passage is split into words. Every word position that is *not*
       just after sentence-ending punctuation becomes a possible entry point —
       so columns start mid-thought, never at a tidy sentence head.
    2. The text is laid out into narrow vertical columns on a wide strip:
       294.21875px pitch, 283.828125px wide, 28 columns per 8238.125px block.
       Column n starts at entry point (37n + 19) % count, is repeated 4x so it
       never runs dry, and is greedy-wrapped to floor(283.828125 / charWidth).
    3. The canvas is walked as a character grid (10px mono, 13px lines). For
       each cell, a 3D value-noise field gives a displacement, and the glyph is
       sampled from the *displaced* position rather than the cell's own. That
       resampling is the entire effect — the prose smears into liquid.
    4. The displacement is scaled by the squared normalized Y, anchored at the
       top. So the top rows stay crisp and legible and the smear grows
       quadratically toward the bottom.
    5. Time is the noise's third dimension (0.1t) plus a -0.08t vertical drift,
       so the field flows instead of jittering.

  Runs of consecutive same-color characters are accumulated into a single
  fillText call rather than one call per glyph — their key perf trick, kept.

  The call to action is stamped into the row nearest the viewport center. Those
  cells always render the CTA character; only the *color* animates, each letter
  flipping to white on a hash-shuffled schedule over 3.2s.

  Drag, scroll, or use the arrow keys to move through the strip.

  Two deliberate changes from the original:
    • The prose is original text written for this gallery, not Nell's own
      company text — the effect is the layout-and-warp pipeline, not their
      marketing copy. Swap the PROSE constant for any long text.
    • The CTA reads "DRAG TO EXPLORE" instead of "JOIN THE WAITLIST", which
      doubles as a usage hint.
    • The wheel handler stands down when this file is in an iframe. The
      original calls preventDefault unconditionally, which is free on a page
      that never scrolls but would trap a host page's scroll here.
  Every geometry, noise, warp, reveal, easing and clamp constant is kept
  exactly as captured.

  Fully self-contained: no external scripts, fonts, or images.
-->
<html lang="en">
<head>
  <link rel="icon" href="data:,">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Warped Text Wall</title>
  <!-- a14y:begin head — machine-readable metadata; safe to delete when copying -->
  <meta name="description" content="A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.">
  <link rel="canonical" href="https://fx.statico.io/effects/nell-text-warp.html">
  <link rel="alternate" type="text/markdown" href="https://fx.statico.io/effects/nell-text-warp.md" title="Warped Text Wall (Markdown)">
  <meta property="og:type" content="website">
  <meta property="og:site_name" content="cool web fx">
  <meta property="og:title" content="Warped Text Wall">
  <meta property="og:description" content="A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.">
  <meta property="og:url" content="https://fx.statico.io/effects/nell-text-warp.html">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="Warped Text Wall">
  <meta name="twitter:description" content="A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.">
  <script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"SoftwareSourceCode","@id":"https://fx.statico.io/effects/nell-text-warp.html#effect","name":"Warped Text Wall","url":"https://fx.statico.io/effects/nell-text-warp.html","abstract":"A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.","description":"A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.","programmingLanguage":"HTML","codeSampleType":"full solution","runtimePlatform":"Web browser","codeRepository":"https://github.com/statico/cool-web-fx","license":"https://unlicense.org/","isBasedOn":"https://nell.ai/","mainEntityOfPage":"https://fx.statico.io/demos/nell-text-warp","dateModified":"2026-07-27T17:24:00-07:00","inLanguage":"en"},{"@type":"BreadcrumbList","@id":"https://fx.statico.io/effects/nell-text-warp.html#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://fx.statico.io/"},{"@type":"ListItem","position":2,"name":"Warped Text Wall","item":"https://fx.statico.io/demos/nell-text-warp"},{"@type":"ListItem","position":3,"name":"Standalone source","item":"https://fx.statico.io/effects/nell-text-warp.html"}]}]}</script>
  <style>.a14y-doc{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}</style>
  <!-- a14y:end head -->
  <style>
    *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

    :root {
      /* Captured from the live site's CSS custom properties. */
      --paper: hsl(0 100% 18.2%);
      --ink:   hsl(351.4 100% 65.7%);
      --cta:   hsl(0 0% 100%);
      --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
                   "Liberation Mono", "Courier New", monospace;
    }

    html, body {
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: var(--paper);
    }

    body { cursor: grab; }
    /* Set on <html> while a drag is in flight, as the original does. */
    html.dragging, html.dragging body { cursor: grabbing; }

    #wall {
      position: fixed;
      inset: 0;
      display: block;
      width: 100%;
      height: 100%;
    }

    /* Shown only if the 2D context is unavailable, so the page is never a
       blank red rectangle. */
    #fallback {
      position: fixed;
      inset: 0;
      display: none;
      place-content: center;
      padding: 2rem;
      color: var(--ink);
      font: 14px/1.6 var(--font-mono);
      text-align: center;
    }
    #fallback.show { display: grid; }
  </style>
</head>
<body>
  <!-- a14y:begin body — visually hidden description; safe to delete when copying -->
  <div class="a14y-doc">
    <h1>Warped Text Wall</h1>
    <p>A wall of text in tall draggable columns, crisp at the top and smeared into hot-pink weather at the bottom as a flow field resamples every character.</p>
    <h2>How it works</h2>
    <p>This is the full-bleed hero on nell.ai — a wall of text you can drag sideways, readable along the top and dissolving into a smear as it falls. The React component is called NellShaderCanvas, which is a misnomer worth calling out: there is no WebGL anywhere in it. It is a plain 2D canvas painting monospace characters with batched fillText. Everything below was read out of their minified chunk (_next/static/chunks/8147-*.js), so this is a port of the real pipeline rather than a guess at the look.</p>
    <p>The column wall. A ~700-word company text is split on whitespace into words. The code then builds an index list f of *mid-sentence entry points* — scanning from word 9 to length - 9, it keeps every position whose previous word does not end a sentence (/[.!?]["")\]]*$/). Those words are laid out into narrow vertical columns: pitch 294.21875px, column width 283.828125px, left pad 10.390625px, 28 columns per block, giving a block width of 8238.125px (294.21875 × 28, exactly). Column *n* takes the text rotated to begin at word f[(37n + 19) % f.length] — so every column opens mid-thought rather than on a tidy capital — concatenates that rotation to itself four times so it can never run dry, then greedy-word-wraps it to floor(283.828125 / charWidth) characters. One quirk is preserved faithfully: because the inner loop variable shadows the outer one in their minified code, column content repeats every 28 columns while column positions keep advancing — so extra blocks lengthen the strip without ever introducing new text.</p>
    <p>The warp is a resample, not a transform. The wall is rendered as a character grid — 10px monospace, 13px line height, with charWidth measured once as measureText("M" × 20).width / 20. For every grid cell the renderer computes a displacement and then samples the character from the displaced position instead of the cell's own. Nothing is rotated, skewed, or scaled; each cell simply asks a different part of the text wall what letter it ought to be. That resampling through a flow field is the whole effect.</p>
    <p>The field is classic 3D value noise: hash fract(sin(127.1x + 311.7y + 74.7z) * 43758.5453123) * 2 - 1, smoothstep-interpolated (t*t*(3-2t)) trilinearly across the lattice. Per cell, with o = max(rectWidth, rectHeight) and t in seconds, a swirl amount s = 1.2 + 2.9 * |noise3(x/o*3, y/o*3 - 0.1t, 0.03t)| warps the lookup coordinates into h = (x/o - 0.5) * s * 2 and u = (y/o - 0.5) * s - 0.08t at depth d = 0.1t, and the displacement is [620 * noise3(h + 31.7, u, d) * l, 780 * noise3(h, u + 47.3, d) * l]. Time enters only as the noise's third dimension (0.1t) plus that slow -0.08t vertical drift, so the field flows rather than jitters.</p>
    <p>l is the falloff, and it is why the thing stays legible. It is anchored at the top of the canvas — their anchor term is always 0 — and computed as the squared normalized Y, so the first rows are essentially undisplaced and the smear grows quadratically as your eye travels down. Crisp text at the top, illegible pink weather at the bottom.</p>
    <p>The call to action is stamped into the grid, not drawn over it. On the real site JOIN THE WAITLIST is written into the row nearest the visual-viewport centre; those cells always render the CTA character, and the only thing that animates is their colour. The reveal order is a hash shuffle — character indices sorted by fract(sin((i+1) * 92821.731) * 1e4) — each index flipping from ink to white once elapsed progress passes (order + 1) / count, across 3.2 seconds. The words don't type in; they precipitate out of the noise in a fixed, deterministic scatter.</p>
    <p>Interaction is one number. Mouse wheel (whichever of deltaX/deltaY is larger in magnitude), pointer drag, and the Arrow keys (one column pitch per press) all feed a single target offset, clamped to [-(8238.125 - innerWidth + 294.21875), 0], which the renderer eases toward with current += (target - current) * 0.16. Pointer-down adds a grabbing-cursor class, and presses landing on links, buttons, inputs, or dialogs are excluded so the page stays usable. prefers-reduced-motion freezes time — it renders the finished 3.2s state and redraws only on interaction — and viewports &lt;= 767px throttle to 24fps with devicePixelRatio capped at 1.5. For performance, runs of consecutive same-coloured characters are accumulated and flushed as a single fillText rather than one call per glyph. The palette comes from CSS custom properties: paper hsl(0 100% 18.2%) (deep oxblood), ink hsl(351.4 100% 65.7%) (hot pink-red), CTA hsl(0 0% 100%).</p>
    <p>Two honest deviations. (1) The text here is placeholder lorem ipsum, not Nell's own company text. The effect is the layout-and-warp pipeline, not their marketing copy, and the text lives in a single clearly-marked constant you can swap for any long body of writing. (2) The CTA reads DRAG TO EXPLORE instead of JOIN THE WAITLIST, which doubles as a usage hint. Every geometry, noise, warp, reveal, easing, and clamp constant above is kept exactly as captured. Fully self-contained: no external scripts, fonts, or images — just the system monospace stack and one 2D canvas.</p>
    <p>One further concession to living in a gallery: the original calls preventDefault on every wheel event, which costs it nothing on a page that never scrolls but would trap the host page's scroll inside an iframe. Here the wheel handler stands down when the file detects it is framed — drag and the arrow keys still move the strip.</p>
    <h2>How to use this file</h2>
    <p>This is a complete, self-contained HTML document — copy it verbatim rather than reconstructing it. Markdown version: <a href="https://fx.statico.io/effects/nell-text-warp.md">https://fx.statico.io/effects/nell-text-warp.md</a>. Full writeup and live preview: <a href="https://fx.statico.io/demos/nell-text-warp">https://fx.statico.io/demos/nell-text-warp</a>.</p>
    <h2>Source and credit</h2>
    <p>Effect originally seen on Nell (https://nell.ai/) — credit to Nell. This is an independent recreation built from scratch for educational use; no proprietary source code was copied. Released under the Unlicense.</p>
    <h2>More</h2>
    <p>Terms used above are defined in the <a href="https://fx.statico.io/glossary">glossary</a>. See also the <a href="https://fx.statico.io/sitemap.md">site map</a>, <a href="https://fx.statico.io/AGENTS.md">AGENTS.md</a> and <a href="https://fx.statico.io/llms.txt">llms.txt</a>.</p>
  </div>
  <!-- a14y:end body -->
  <canvas id="wall" aria-hidden="true"></canvas>
  <div id="fallback">This effect needs a 2D canvas context.</div>

<script>
(() => {
  'use strict';

  // ── Tunables ───────────────────────────────────────────────────────────────
  const CTA = 'DRAG TO EXPLORE';

  // Geometry, captured from the original.
  const COLUMN_PITCH     = 294.21875;
  const COLUMN_WIDTH     = 283.828125;
  const COLUMN_PAD       = 10.390625;
  const COLUMNS_PER_BLOCK = 28;
  const BLOCK_WIDTH      = 8238.125;   // === COLUMN_PITCH * COLUMNS_PER_BLOCK
  const FONT_SIZE        = 10;
  const LINE_HEIGHT      = 13;

  // Warp, easing, reveal.
  const WARP_X          = 620;
  const WARP_Y          = 780;
  const EASE            = 0.16;
  const REVEAL_SECONDS  = 3.2;
  const MOBILE_FRAME_MS = 1000 / 24;
  const MOBILE_DPR_CAP  = 1.5;

  // ── The text ───────────────────────────────────────────────────────────────
  // Placeholder lorem ipsum. Swap it for anything long; the layout adapts.
  // It only wants many sentence boundaries to pick entry points from.
  const PROSE = `
  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Eaque soluta porro
  quaerat, at distinctio labore eligendi eu ut. Adipiscing asperiores cumque
  nihil cillum debitis rem accusantium ad elit ipsum quis similique lorem
  praesentium expedita. Dolor nam sequi deserunt consequuntur excepturi rem.
  Minus id sapiente minus at porro irure nulla? Doloremque culpa excepturi
  nobis quaerat aut eiusmod, voluptas veniam libero proident provident. Harum
  autem fugit nam doloremque autem nam consequat labore dolor qui et minus
  eligendi natus cupidatat mollitia? Incididunt modi non quidem irure do natus
  quos similique minim magna occaecat pariatur. Cumque dignissimos pariatur
  lorem, voluptatum enim aute culpa placeat beatae doloribus proident aliqua
  minim dolorum! Alias non porro iusto eius aliqua excepturi culpa omnis
  repudiandae. Dicta cupidatat ullamco hic explicabo dolor alias dicta
  blanditiis. Temporibus itaque anim rerum cillum repellendus magna voluptatum
  quia aspernatur dolorum, magna perferendis est. Aliquip at dolorem debitis
  distinctio commodo dolorem doloremque. Reiciendis voluptatum quo facilis
  consequuntur officiis magnam voluptatum in libero molestias culpa natus. Sit
  at asperiores ullamco minus amet, duis explicabo pariatur molestias modi.
  Modi anim odio repudiandae sit delectus anim at dolor ratione non tempor ea
  irure id enim id. Amet illo eligendi quae excepteur occaecat anim et. Sed ex
  nisi aliqua error cum beatae. Irure cupidatat doloribus temporibus ratione
  maxime asperiores aute enim dignissimos, inventore ad cillum sit accusantium
  quia dolor. Deleniti proident in facilis mollitia tempor eligendi. Nisi
  totam in cupidatat nulla natus officia. At odio voluptate similique
  blanditiis dignissimos rem est. Nostrud ipsum repudiandae debitis, magna non
  minus ipsum animi assumenda sint minim beatae dolores. Ad reiciendis dolorum
  corrupti recusandae ipsum eu explicabo ducimus rerum facere. Eiusmod
  voluptatem occaecat nesciunt modi ratione provident quis qui hic autem
  assumenda soluta vitae. Ea doloribus maxime enim asperiores fuga voluptatem?
  Eu reiciendis impedit incidunt ipsum deserunt dicta sunt asperiores. Ipsa
  hic quas quisquam dolore dolor eaque natus. Repudiandae eos culpa aliqua
  autem incidunt hic. Consequuntur facere molestias pariatur vitae doloremque
  minim eligendi possimus numquam incidunt. Recusandae qui possimus aliquam
  eos optio aliquam soluta excepteur ex placeat aute, reprehenderit laboris
  libero excepturi. Magnam soluta possimus cum praesentium, voluptatum aperiam
  enim laudantium veritatis error. Aliquip maiores sunt dicta quo neque eaque.
  Modi eiusmod quos voluptate numquam aspernatur aliquam explicabo
  consequuntur atque tempore. Facilis vitae ab occaecati voluptate corrupti
  animi magna mollitia, culpa mollit labore? Est ea qui vitae mollit sed
  sapiente explicabo, eu ipsum vitae. Saepe do iusto perferendis at ullamco
  deserunt laborum maxime commodo itaque delectus vitae mollitia consectetur
  omnis. Minus fugiat magni nulla eu porro voluptates vero nihil numquam fuga
  quas laborum autem id recusandae tenetur. Quis ipsum itaque facilis aut
  incidunt aspernatur veritatis accusamus nesciunt reiciendis quae adipisci
  necessitatibus excepteur asperiores ipsa. Excepteur repellendus voluptate
  duis repellendus beatae laborum consequat aperiam ratione natus exercitation
  necessitatibus non qui dolor culpa. Nihil magni nesciunt beatae incididunt
  ad ullamco maiores, cumque incididunt deleniti enim provident dolores esse.
  Ratione similique exercitation repellat similique vitae aliquip nobis lorem.
  Tenetur error explicabo autem, occaecati temporibus blanditiis eligendi
  reprehenderit aut nam in tempore doloremque iusto! Dolores aut facilis alias
  repudiandae optio officia neque expedita fuga, eu quisquam eius ab. Duis
  inventore accusamus quisquam quibusdam, at voluptas cum animi id inventore
  tempore debitis dolor. Magni voluptates culpa impedit necessitatibus esse
  laudantium deserunt. Sequi itaque ut eu magna et voluptas tenetur.
  Repudiandae earum ipsa alias cillum voluptates consectetur laborum, adipisci
  amet dolor modi cupidatat sequi porro. Inventore debitis culpa ipsum, modi
  ipsum maiores praesentium dolorem laudantium. Qui voluptas vitae
  consequuntur elit mollitia adipisci. Fuga rerum at soluta inventore vero
  repellendus fugit. Iusto doloribus culpa quas reiciendis natus optio
  commodo? Quis repudiandae consequuntur incididunt voluptates accusantium
  ipsum sint quaerat perferendis voluptatum voluptate minus nam dolor. Tempore
  blanditiis sequi nesciunt occaecati incididunt labore deserunt, facilis
  autem illo. Aute recusandae omnis sunt dolores ratione quisquam? Odio quia
  aut voluptates accusamus alias omnis earum distinctio excepteur
  necessitatibus. Quos aspernatur placeat ex assumenda ad earum quia! Magna
  eligendi rerum eveniet sapiente dolorum eu numquam. Porro irure aperiam
  aliqua enim amet et repellendus sequi, aute ratione accusantium excepturi.
  Irure fugit lorem at dolor distinctio perferendis, neque eaque laborum sit
  natus deserunt eaque illo quae velit. Eos dolorum totam aliqua porro, mollit
  aut quas quisquam provident amet consequuntur impedit eaque magni. Possimus
  magna voluptatum temporibus eligendi sed ex voluptates id rem ullamco. Amet
  assumenda rerum quidem inventore elit tempore elit eaque rem. Maiores quo
  veritatis debitis optio reprehenderit nesciunt doloremque. Cumque quisquam
  similique occaecati debitis accusantium, laborum maxime repellat. Tempor
  corrupti in excepteur ex debitis totam dolorum velit. Occaecat harum alias
  quisquam incididunt deserunt nihil repudiandae proident culpa at hic ipsum.
  Occaecati eius dicta dignissimos sequi aliqua debitis ducimus reprehenderit
  occaecati, reprehenderit veritatis fugiat accusantium tempore. Deserunt
  harum nostrud occaecat neque tempor praesentium sequi neque cumque
  blanditiis enim optio. Quas cupidatat fugiat error ducimus in debitis!
  Impedit similique dolorem exercitation aut at officia quas totam. Deleniti
  ad ea sit atque voluptatum quis quaerat quidem quos. Aute placeat enim
  voluptas eos magna sit vero quis, lorem consequuntur voluptatem aut
  praesentium nostrud. Adipisci consequat dolor eu laudantium tempore
  cupidatat culpa quos non saepe. Dolor aliqua tempore dolor, quisquam ipsum
  deserunt nobis qui quisquam optio placeat aspernatur repudiandae quibusdam
  quas.
`;

  const WORDS = PROSE.replace(/\s+/g, ' ').trim().split(' ');

  // Word positions that are NOT immediately after sentence-ending punctuation,
  // i.e. mid-sentence entry points. Columns start here so the wall reads as an
  // endless middle rather than a grid of openings.
  const ENTRY_POINTS = [];
  for (let i = 9; i < WORDS.length - 8; i += 1) {
    const prev = WORDS[i - 1] ?? '';
    if (!/[.!?]["'’”)\]]*$/.test(prev)) ENTRY_POINTS.push(i);
  }

  // ── Value noise ────────────────────────────────────────────────────────────
  function hash3(x, y, z) {
    const n = Math.sin(127.1 * x + 311.7 * y + 74.7 * z) * 43758.5453123;
    return (n - Math.floor(n)) * 2 - 1;
  }

  const smoothstep = (t) => t * t * (3 - 2 * t);
  const mix = (a, b, t) => a + (b - a) * t;

  function noise3(x, y, z) {
    const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
    const fx = smoothstep(x - xi), fy = smoothstep(y - yi), fz = smoothstep(z - zi);

    const y0z0 = mix(hash3(xi, yi,     zi),     hash3(xi + 1, yi,     zi),     fx);
    const y1z0 = mix(hash3(xi, yi + 1, zi),     hash3(xi + 1, yi + 1, zi),     fx);
    const y0z1 = mix(hash3(xi, yi,     zi + 1), hash3(xi + 1, yi,     zi + 1), fx);
    const y1z1 = mix(hash3(xi, yi + 1, zi + 1), hash3(xi + 1, yi + 1, zi + 1), fx);

    return mix(mix(y0z0, y1z0, fy), mix(y0z1, y1z1, fy), fz);
  }

  // ── Text layout ────────────────────────────────────────────────────────────
  const textCache    = new Map();
  const wrapCache    = new Map();
  const metricsCache = new Map();
  const blockCache   = new Map();

  function columnText(index) {
    const hit = textCache.get(index);
    if (hit) return hit;
    const start = ENTRY_POINTS[(37 * index + 19) % ENTRY_POINTS.length] || 0;
    const rotated = [...WORDS.slice(start), ...WORDS.slice(0, start)].join(' ');
    textCache.set(index, rotated);
    return rotated;
  }

  function wrapColumn(index, maxChars) {
    const key = index + ':' + maxChars;
    const hit = wrapCache.get(key);
    if (hit) return hit;

    // Repeated 4x so a tall viewport can never run past the end.
    const base = columnText(index);
    let text = base;
    for (let r = 1; r < 4; r += 1) text += ' ' + base;

    const lines = [];
    let line = '';
    for (const word of text.split(' ')) {
      const next = line ? line + ' ' + word : word;
      if (next.length > maxChars && line) {
        lines.push(line);
        line = word;
      } else {
        line = next;
      }
    }
    if (line) lines.push(line);

    wrapCache.set(key, lines);
    return lines;
  }

  function metricsFor(ctx, fontFamily) {
    const font = FONT_SIZE + 'px ' + fontFamily;
    ctx.font = font;
    const hit = metricsCache.get(font);
    if (hit) return hit;

    // Averaged over 20 glyphs so sub-pixel advance widths don't accumulate.
    const probe = 'M'.repeat(20);
    const charWidth = Math.max(1, ctx.measureText(probe).width / 20);
    const metrics = { font, charWidth, lineHeight: LINE_HEIGHT };
    metricsCache.set(font, metrics);
    return metrics;
  }

  // Column *content* repeats every 28; only the positions keep advancing.
  function buildColumns(offset, viewportWidth, metrics) {
    const blocks = Math.max(
      2,
      Math.ceil((viewportWidth + Math.abs(offset) + BLOCK_WIDTH) / BLOCK_WIDTH)
    );
    const key = blocks + ':' + metrics.charWidth.toFixed(2);
    const hit = blockCache.get(key);
    if (hit) return hit;

    const maxChars = Math.max(1, Math.floor(COLUMN_WIDTH / metrics.charWidth));
    const columns = [];
    for (let b = 0; b < blocks; b += 1) {
      for (let i = 0; i < COLUMNS_PER_BLOCK; i += 1) {
        columns.push({
          anchor: 0,
          left: COLUMN_PITCH * columns.length + COLUMN_PAD,
          width: COLUMN_WIDTH,
          lines: wrapColumn(i, maxChars),
        });
      }
    }

    blockCache.set(key, columns);
    return columns;
  }

  function columnAt(columns, x) {
    const column = columns[Math.floor(x / COLUMN_PITCH)];
    return column && x >= column.left && x < column.left + column.width
      ? column
      : undefined;
  }

  // ── The warp ───────────────────────────────────────────────────────────────
  function warp(x, y, time, width, height, metrics, anchor) {
    const span = Math.max(width, height);

    // Squared falloff from the anchor row (always the top here), so the crisp
    // band sits up top and the smear grows toward the bottom.
    const normY = Math.max(0, Math.min(1, y / Math.max(height - metrics.lineHeight, 1)));
    const falloff =
      (Math.abs(normY - anchor) / Math.max(anchor, 1 - anchor, 0.001)) ** 2;

    const swirl = 1.2 + 2.9 * Math.abs(
      noise3((x / span) * 3, (y / span) * 3 - 0.1 * time, 0.03 * time)
    );

    const nx = (x / span - 0.5) * swirl * 2;
    const ny = (y / span - 0.5) * swirl - 0.08 * time;
    const nz = 0.1 * time;

    return [
      WARP_X * noise3(nx + 31.7, ny, nz) * falloff,
      WARP_Y * noise3(nx, ny + 47.3, nz) * falloff,
    ];
  }

  // Sample a glyph from the laid-out strip at an arbitrary (displaced) point.
  function charAt(columns, x, y, metrics) {
    if (y < 0) return ' ';
    const column = columnAt(columns, x);
    if (!column) return ' ';

    const line = column.lines[Math.floor(y / metrics.lineHeight)];
    if (!line) return ' ';

    // Nudge values that land a hair under an integer boundary up to it, so
    // glyphs don't shear along column edges.
    const raw = (x - column.left) / metrics.charWidth;
    const floored = Math.floor(raw);
    const index = Math.abs(raw - floored - 1) < 1e-6 ? Math.ceil(raw) : floored;

    return line[index] || ' ';
  }

  // ── Call to action ─────────────────────────────────────────────────────────
  function ctaHash(i) {
    const t = Math.sin((i + 1) * 92821.731) * 1e4;
    return t - Math.floor(t);
  }

  // Reveal order is a hash shuffle of the non-space character indices.
  const CTA_ORDER = Array.from(CTA, (_, i) => i)
    .filter((i) => CTA[i] !== ' ')
    .sort((a, b) => ctaHash(a) - ctaHash(b));
  const CTA_RANK = new Map(CTA_ORDER.map((charIndex, rank) => [charIndex, rank]));

  function ctaIndexAt(center, metrics, x, y) {
    const rowY =
      Math.round((center.y - metrics.lineHeight / 2) / metrics.lineHeight) *
      metrics.lineHeight;
    if (Math.abs(y - rowY) > 0.01) return null;

    const left = center.x - (CTA.length * metrics.charWidth) / 2;
    const index = Math.floor((x + metrics.charWidth / 2 - left) / metrics.charWidth);
    return index >= 0 && index < CTA.length ? index : null;
  }

  function ctaRevealed(index, progress) {
    const rank = CTA_RANK.get(index);
    return rank !== undefined && progress >= (rank + 1) / CTA_ORDER.length;
  }

  // ── Setup ──────────────────────────────────────────────────────────────────
  const canvas = document.getElementById('wall');
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    document.getElementById('fallback').classList.add('show');
    return;
  }

  const readVar = (name) =>
    getComputedStyle(canvas).getPropertyValue(name).trim();

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const smallScreen  = window.matchMedia('(max-width: 767px)');

  let frame = null;
  let easedOffset = 0;      // what is drawn
  let targetOffset = 0;     // what input drives
  let dragOriginX = 0;
  let dragOriginOffset = 0;
  let dragPointer = null;
  let lastFrameAt = 0;

  let isSmall = smallScreen.matches;
  let isReduced = reduceMotion.matches;

  let paper = '', ink = '', cta = '', fontFamily = '';
  const startedAt = performance.now() / 1000;

  const dpr = () => {
    const ratio = window.devicePixelRatio || 1;
    return isSmall ? Math.min(ratio, MOBILE_DPR_CAP) : ratio;
  };

  function syncSurface() {
    const ratio = dpr();
    const rect = canvas.getBoundingClientRect();
    const w = Math.round(rect.width * ratio);
    const h = Math.round(rect.height * ratio);
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
    ctx.setTransform(ratio, 0, 0, ratio, 0, 0);

    paper      = readVar('--paper');
    ink        = readVar('--ink');
    cta        = readVar('--cta');
    fontFamily = readVar('--font-mono');
  }

  // The strip is finite; clamp so you can never drag past its ends.
  function clampOffset(value) {
    const min = -(BLOCK_WIDTH - window.innerWidth + COLUMN_PITCH);
    return Math.min(0, Math.max(value, min));
  }

  function schedule() {
    if (frame === null) frame = requestAnimationFrame(render);
  }

  // Flush a batched run of same-colored glyphs in one fillText call.
  function flush(run, startX, style, y) {
    if (!run) return;
    ctx.fillStyle = style;
    ctx.fillText(run.toUpperCase(), startX, y);
  }

  function draw(time) {
    const ratio = dpr();
    const rect = canvas.getBoundingClientRect();
    if (
      canvas.width !== Math.round(rect.width * ratio) ||
      canvas.height !== Math.round(rect.height * ratio)
    ) {
      syncSurface();
    }

    ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    ctx.clearRect(0, 0, rect.width, rect.height);
    ctx.fillStyle = paper;
    ctx.fillRect(0, 0, rect.width, rect.height);
    ctx.save();
    ctx.textBaseline = 'top';

    const metrics = metricsFor(ctx, fontFamily);
    const columns = buildColumns(easedOffset, window.innerWidth, metrics);

    const center = { x: rect.width / 2, y: rect.height / 2 };
    const progress = Math.min(
      1,
      (isReduced ? REVEAL_SECONDS : Math.max(0, time - startedAt)) / REVEAL_SECONDS
    );

    const shift = -easedOffset;
    // Keep glyph cells on a stable sub-pixel lattice while scrolling.
    const startX =
      (((easedOffset + COLUMN_PAD) % metrics.charWidth) + metrics.charWidth) %
        metrics.charWidth -
      metrics.charWidth;

    ctx.font = metrics.font;

    for (let y = 0; y < rect.height; y += metrics.lineHeight) {
      let run = '';
      let runStartX = startX;
      let runStyle = ink;

      for (let x = startX; x < rect.width + metrics.charWidth; x += metrics.charWidth) {
        const column = columnAt(columns, x + shift);
        const [dx, dy] = warp(
          x, y, time, rect.width, rect.height, metrics,
          column ? column.anchor : 0
        );

        const ctaIndex = ctaIndexAt(center, metrics, x, y);
        const glyph =
          ctaIndex === null
            ? charAt(columns, x + shift + dx, y + dy, metrics)
            : CTA[ctaIndex];
        const style =
          ctaIndex !== null && ctaRevealed(ctaIndex, progress) ? cta : ink;

        if (style !== runStyle) {
          flush(run, runStartX, runStyle, y);
          run = '';
          runStartX = x;
          runStyle = style;
        }
        run += glyph;
      }

      flush(run, runStartX, runStyle, y);
    }

    ctx.restore();
  }

  function render(now) {
    frame = null;

    // Mobile runs at 24fps; desktop is uncapped.
    const budget = isSmall ? MOBILE_FRAME_MS : 0;
    if (!isReduced && budget > 0 && lastFrameAt > 0 && now - lastFrameAt < budget) {
      schedule();
      return;
    }
    lastFrameAt = now;

    easedOffset = isReduced
      ? targetOffset
      : easedOffset + (targetOffset - easedOffset) * EASE;

    draw(isReduced ? 0 : performance.now() / 1000);

    // Reduced motion draws a still frame and only redraws on input.
    if (!isReduced) schedule();
  }

  // ── Input ──────────────────────────────────────────────────────────────────
  // Don't hijack gestures aimed at real controls.
  const isInteractive = (target) =>
    target instanceof Element &&
    !!target.closest('a, button, input, textarea, select, [role="dialog"]');

  // The original swallows the wheel outright, which it can afford because its
  // page never scrolls. Embedded in a gallery iframe that would trap the host
  // page's scroll, so when framed the wheel is left alone and drag / arrow
  // keys do the moving.
  const framed = window.top !== window.self;

  const onWheel = (e) => {
    if (framed || isInteractive(e.target)) return;
    e.preventDefault();
    const delta =
      Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    targetOffset = clampOffset(targetOffset - delta);
    if (isReduced) schedule();
  };

  const onPointerDown = (e) => {
    if (isInteractive(e.target)) return;
    dragOriginX = e.clientX;
    dragOriginOffset = targetOffset;
    dragPointer = e.pointerId;
    document.documentElement.classList.add('dragging');
  };

  const onPointerMove = (e) => {
    if (dragPointer !== e.pointerId) return;
    targetOffset = clampOffset(dragOriginOffset + (e.clientX - dragOriginX));
    if (isReduced) schedule();
  };

  const onPointerUp = (e) => {
    if (dragPointer !== e.pointerId) return;
    dragPointer = null;
    document.documentElement.classList.remove('dragging');
  };

  const onKeyDown = (e) => {
    if (isInteractive(e.target)) return;
    if (e.key === 'ArrowRight') targetOffset -= COLUMN_PITCH;
    else if (e.key === 'ArrowLeft') targetOffset += COLUMN_PITCH;
    else return;
    targetOffset = clampOffset(targetOffset);
    if (isReduced) schedule();
  };

  const onResize = () => {
    syncSurface();
    targetOffset = clampOffset(targetOffset);
    if (isReduced) schedule();
  };

  reduceMotion.addEventListener('change', (e) => {
    isReduced = e.matches;
    schedule();
  });
  smallScreen.addEventListener('change', (e) => {
    isSmall = e.matches;
    lastFrameAt = 0;
    syncSurface();
    schedule();
  });

  window.addEventListener('resize', onResize);
  window.addEventListener('wheel', onWheel, { passive: false });
  window.addEventListener('pointerdown', onPointerDown);
  window.addEventListener('pointermove', onPointerMove);
  window.addEventListener('pointerup', onPointerUp);
  window.addEventListener('pointercancel', onPointerUp);
  window.addEventListener('keydown', onKeyDown);

  syncSurface();
  schedule();
})();
</script>
</body>
</html>

Source & credit

Effect seen on https://nell.ai/ — credit to Nell.

This is an independent recreation of the effect built from scratch for educational use. No proprietary source code was copied.