Live demo, running in your browser

Felt fox

A royal-blue hero with a huge cream HELLO word behind a needle-felted fox cutout that springs its head toward the cursor (or wanders on its own), nods on button click, while the letters drift in depth and wool fibres float over film grain.

Assets

Code: MIT license. Imagery generated with Google Gemini

<!doctype html>
<html lang="en">
  <head
    ><script>
      /* 6to9 pattern harness */ (function () {
        var parentPaused = false,
          hidden = document.hidden,
          p = (window.__pattern = { paused: false, reducedMotion: false, maxDpr: 2 });
        function sync() {
          p.paused = parentPaused || hidden;
        }
        sync();
        try {
          p.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
        } catch (e) {}
        document.addEventListener("visibilitychange", function () {
          hidden = document.hidden;
          sync();
        });
        addEventListener("message", function (e) {
          var d = e.data || {};
          if (d.pattern === "pause") {
            parentPaused = true;
            sync();
          }
          if (d.pattern === "play") {
            parentPaused = false;
            sync();
          }
        });
      })();
    </script>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tuft &amp; Tale — Hello</title>
    <style>
      html,
      body {
        margin: 0;
        height: 100%;
        overflow: hidden;
        background: #1d3fd8;
      }
      .hero {
        position: relative;
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        background: radial-gradient(ellipse at 50% 42%, #2c58f2 0%, #1d3fd8 55%, #142ca3 100%);
        font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, system-ui, sans-serif;
        color: #f6ead2;
      }
      canvas.layer {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
      }
      .grain {
        mix-blend-mode: overlay;
        opacity: 0.45;
        z-index: 6;
      }
      .fibers {
        z-index: 1;
      }
      .word {
        position: absolute;
        left: 50%;
        top: 40%;
        z-index: 2;
        white-space: nowrap;
        font-family: "Arial Black", "Helvetica Neue", Impact, system-ui, sans-serif;
        font-weight: 900;
        font-size: 28vw;
        line-height: 0.75;
        letter-spacing: -0.04em;
        color: #f6ead2;
        text-shadow: 0 0.015em 0 rgba(10, 20, 100, 0.18);
        user-select: none;
        transform: translate(-50%, -50%);
      }
      .word span {
        display: inline-block;
        will-change: transform;
      }
      .fox {
        position: absolute;
        bottom: 0;
        z-index: 3;
        transform-origin: 50% 100%;
        will-change: transform;
        pointer-events: none;
      }
      .fox img {
        display: block;
        width: 100%;
        height: 100%;
        object-fit: contain;
        filter: drop-shadow(0 24px 28px rgba(8, 18, 90, 0.45));
        opacity: 0;
        transition: opacity 0.8s ease;
        user-select: none;
        -webkit-user-drag: none;
      }
      .fox img.ready {
        opacity: 1;
      }
      .brand {
        position: absolute;
        top: 24px;
        left: 4vw;
        z-index: 5;
        font-weight: 800;
        font-size: 17px;
        letter-spacing: 0.02em;
      }
      .copy {
        position: absolute;
        left: 4vw;
        bottom: 5vh;
        z-index: 5;
        max-width: 320px;
      }
      .copy p {
        margin: 0 0 18px;
        font-size: 18px;
        line-height: 1.4;
        font-weight: 600;
      }
      .pill {
        display: inline-flex;
        align-items: center;
        gap: 10px;
        padding: 15px 28px;
        border: 0;
        border-radius: 999px;
        background: #f6ead2;
        color: #1d3fd8;
        font: inherit;
        font-weight: 800;
        font-size: 16px;
        cursor: pointer;
        box-shadow: 0 6px 0 rgba(8, 18, 90, 0.35);
        transition:
          transform 0.2s ease,
          box-shadow 0.2s ease;
      }
      .pill:hover {
        transform: translateY(-2px);
        box-shadow: 0 8px 0 rgba(8, 18, 90, 0.35);
      }
      .pill:active {
        transform: translateY(3px);
        box-shadow: 0 2px 0 rgba(8, 18, 90, 0.35);
      }
      .pill:focus-visible {
        outline: 3px solid #e3a92c;
        outline-offset: 3px;
      }
      @media (max-width: 700px) {
        .brand {
          left: 20px;
          top: 20px;
        }
        .copy {
          left: 20px;
          right: 20px;
          top: 60px;
          bottom: auto;
          max-width: none;
        }
        .copy p {
          font-size: 15px;
          margin-bottom: 12px;
        }
        .pill {
          padding: 12px 22px;
          font-size: 15px;
        }
      }
    </style>
  </head>
  <body>
    <main class="hero" id="hero">
      <canvas class="layer fibers" id="fibers"></canvas>
      <div class="brand">Tuft &amp; Tale</div>
      <div class="word" id="word" aria-label="Hello"
        ><span>H</span><span>E</span><span>L</span><span>L</span><span>O</span></div
      >
      <div class="fox" id="fox">
        <img
          id="foxImg"
          alt="A needle-felted fox with teal yarn hair and a mustard knitted scarf"
          crossorigin="anonymous"
          src="https://agents-dev.6to9.ai/visuals/63dd22d156b131f38fb299340eb664de.png"
          draggable="false"
        />
      </div>
      <div class="copy">
        <p>Meet Juniper, a hand-felted fox who is always curious about you.</p>
        <button class="pill" id="cta" type="button"
          >Say hello back <span aria-hidden="true">&rarr;</span></button
        >
      </div>
      <canvas class="layer grain" id="grain"></canvas>
    </main>
    <script>
      (function () {
        var P = window.__pattern || { paused: false, reducedMotion: false, maxDpr: 2 };
        var hero = document.getElementById("hero");
        var wordEl = document.getElementById("word");
        var letters = Array.prototype.slice.call(wordEl.querySelectorAll("span"));
        var fox = document.getElementById("fox");
        var foxImg = document.getElementById("foxImg");
        var fibersCanvas = document.getElementById("fibers");
        var grainCanvas = document.getElementById("grain");
        var fctx = fibersCanvas.getContext("2d");

        var vw = 0;
        var vh = 0;
        var dpr = 1;
        var size = 0;
        var mobile = false;
        var wordScaleY = 1;

        // Pose state: current (c), velocity (v), target (t), all in -1..1
        var pose = { cx: 0, cy: 0, vx: 0, vy: 0, tx: 0, ty: 0 };
        var lastPointer = -99999;
        var nodStart = -99;
        var stillDrawn = false;
        var lastNow = 0;
        var clock = 0;

        // Loose wool fibres drifting in the air
        var palette = ["#f6ead2", "#2fbfb0", "#e3a92c", "#f6ead2"];
        var fibers = [];
        for (var i = 0; i < 46; i++) {
          fibers.push({
            x: Math.random(),
            y: Math.random(),
            len: 14 + Math.random() * 30,
            a: Math.random() * Math.PI * 2,
            speed: 4 + Math.random() * 10,
            ph: Math.random() * 6.28,
            depth: 0.3 + Math.random() * 1.2,
            bend: (Math.random() - 0.5) * 22,
            col: palette[i % palette.length],
            alpha: 0.25 + Math.random() * 0.4,
          });
        }

        function clamp(v, a, b) {
          return Math.max(a, Math.min(b, v));
        }

        function drawGrain() {
          var gw = Math.max(2, Math.floor(vw * dpr * 0.6));
          var gh = Math.max(2, Math.floor(vh * dpr * 0.6));
          grainCanvas.width = gw;
          grainCanvas.height = gh;
          var g = grainCanvas.getContext("2d");
          if (!g) {
            return;
          }
          var img = g.createImageData(gw, gh);
          for (var p = 0; p < img.data.length; p += 4) {
            var n = 90 + Math.random() * 120;
            img.data[p] = n;
            img.data[p + 1] = n;
            img.data[p + 2] = n;
            img.data[p + 3] = 255;
          }
          g.putImageData(img, 0, 0);
        }

        function layout() {
          vw = window.innerWidth;
          vh = window.innerHeight;
          dpr = Math.min(window.devicePixelRatio || 1, P.maxDpr || 2);
          mobile = vw < 700;

          fibersCanvas.width = Math.floor(vw * dpr);
          fibersCanvas.height = Math.floor(vh * dpr);
          drawGrain();

          // Fox: centred, bottom-aligned
          size = mobile ? Math.min(vw * 1.08, vh * 0.58) : Math.min(vh * 0.88, vw * 0.95);
          fox.style.width = size + "px";
          fox.style.height = size + "px";
          fox.style.left = (vw - size) / 2 + "px";

          // Word: fit to full width, then centre it behind the head
          wordEl.style.fontSize = "100px";
          var w100 = wordEl.offsetWidth || 340;
          var fs = (100 * (vw * 0.97)) / w100;
          wordEl.style.fontSize = fs + "px";
          wordScaleY = mobile ? 2.1 : 1.05;
          var centerY = mobile ? vh * 0.38 : vh - size + size * 0.3;
          wordEl.style.top = centerY + "px";
          wordEl.style.transform = "translate(-50%, -50%) scaleY(" + wordScaleY + ")";

          stillDrawn = false;
        }

        function onPointer(e) {
          var headY = vh - size * 0.6;
          pose.tx = clamp((e.clientX - vw / 2) / (vw / 2), -1, 1);
          pose.ty = clamp((e.clientY - headY) / (vh / 2), -1, 1);
          lastPointer = performance.now();
        }

        function render(t) {
          var x = pose.cx;
          var y = pose.cy;
          var breathe = Math.sin(t * 1.6) * 0.006;

          var nodT = (t - nodStart) / 0.9;
          var nod = 0;
          if (nodT >= 0 && nodT <= 1) {
            nod = Math.sin(nodT * Math.PI * 2) * (1 - nodT) * 7;
          }

          var rotY = x * 15;
          var rotX = y * -7 + nod;
          var rotZ = x * 1.8;
          var shiftX = x * size * 0.025;
          var shiftY = Math.sin(t * 1.6) * -3;
          fox.style.transform =
            "perspective(1400px) translate3d(" +
            shiftX.toFixed(2) +
            "px," +
            shiftY.toFixed(2) +
            "px,0) " +
            "rotateY(" +
            rotY.toFixed(2) +
            "deg) rotateX(" +
            rotX.toFixed(2) +
            "deg) rotateZ(" +
            rotZ.toFixed(2) +
            "deg) " +
            "scale(" +
            (1 + breathe).toFixed(4) +
            "," +
            (1 + breathe * 2).toFixed(4) +
            ")";

          // Letters sit deeper in space: they slide against the head turn and bob gently
          for (var i = 0; i < letters.length; i++) {
            var k = i - 2;
            var lx = -x * (10 + Math.abs(k) * 6) * (vw / 1440 + 0.4) + 0;
            var ly = Math.sin(t * 0.9 + i * 0.8) * 5 + y * 6 * (1 - Math.abs(k) * 0.15);
            var lr = Math.sin(t * 0.7 + i * 1.3) * 0.6 + x * k * -0.6;
            letters[i].style.transform =
              "translate3d(" +
              lx.toFixed(2) +
              "px," +
              (ly / wordScaleY).toFixed(2) +
              "px,0) rotate(" +
              lr.toFixed(2) +
              "deg)";
          }

          drawFibers(t, x);
        }

        function drawFibers(t, x) {
          if (!fctx) {
            return;
          }
          var W = fibersCanvas.width;
          var H = fibersCanvas.height;
          fctx.clearRect(0, 0, W, H);
          fctx.lineCap = "round";
          fctx.lineWidth = Math.max(1.5, 2.2 * dpr);
          for (var i = 0; i < fibers.length; i++) {
            var f = fibers[i];
            var px = f.x * W + Math.sin(t * 0.4 + f.ph) * 30 * dpr - x * f.depth * 40 * dpr;
            var py = (((f.y * H - f.speed * t * dpr) % H) + H) % H;
            var a = f.a + Math.sin(t * 0.6 + f.ph) * 0.4;
            var L = f.len * dpr;
            var ex = px + Math.cos(a) * L;
            var ey = py + Math.sin(a) * L;
            var cxp = px + Math.cos(a) * L * 0.5 + f.bend * dpr;
            var cyp = py + Math.sin(a) * L * 0.5 - f.bend * dpr;
            fctx.globalAlpha = f.alpha;
            fctx.strokeStyle = f.col;
            fctx.beginPath();
            fctx.moveTo(px, py);
            fctx.quadraticCurveTo(cxp, cyp, ex, ey);
            fctx.stroke();
          }
          fctx.globalAlpha = 1;
        }

        function frame(now) {
          requestAnimationFrame(frame);
          if (P.paused) {
            lastNow = now;
            return;
          }
          if (P.reducedMotion) {
            if (!stillDrawn) {
              pose.tx = 0.3;
              pose.ty = -0.05;
              pose.cx = pose.tx;
              pose.cy = pose.ty;
              pose.vx = 0;
              pose.vy = 0;
              render(0);
              stillDrawn = true;
            }
            return;
          }
          var dt = Math.min(0.05, Math.max(0.001, (now - lastNow) / 1000));
          lastNow = now;
          clock += dt;

          // With no cursor activity she looks around on her own, curious
          if (now - lastPointer > 2500) {
            pose.tx = Math.sin(clock * 0.45) * 0.7;
            pose.ty = Math.sin(clock * 0.31 + 1.2) * 0.25 - 0.05;
          }

          // Damped spring gives a soft, slightly overshooting head turn
          var ax = (pose.tx - pose.cx) * 60 - pose.vx * 12;
          var ay = (pose.ty - pose.cy) * 60 - pose.vy * 12;
          pose.vx += ax * dt;
          pose.vy += ay * dt;
          pose.cx += pose.vx * dt;
          pose.cy += pose.vy * dt;

          render(clock);
        }

        foxImg.addEventListener("load", function () {
          foxImg.classList.add("ready");
        });
        foxImg.addEventListener("error", function () {
          foxImg.style.display = "none";
        });
        if (foxImg.complete && foxImg.naturalWidth > 0) {
          foxImg.classList.add("ready");
        }

        document.getElementById("cta").addEventListener("click", function () {
          nodStart = clock;
          stillDrawn = false;
        });

        window.addEventListener("pointermove", onPointer, { passive: true });
        window.addEventListener("pointerdown", onPointer, { passive: true });
        window.addEventListener("resize", layout);

        layout();
        requestAnimationFrame(function (now) {
          lastNow = now;
          frame(now);
        });
      })();
    </script>
  </body>
</html>

Made by 6to9. Back to all motion patterns.