← Cards & images

Sticky Notes Flutter

Sticky notes on a board lift and flutter as if someone had opened a window. Gusts roll across from one side, catching each note in turn, and the notes shade and cast a deeper shadow as their bottom edge lifts. Move the mouse or drag a finger over them and the nearby notes flap in its wake.

Call Sam back
Buy oat milk
Ship it on Friday!
Water the plants
Book the dentist
Logo idea: a rocket?
Pay the rent
Smile :)

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sticky-notes-flutter to your board, with the notes as its children. Style the notes as you like.
  3. Each note hangs from a strip of tape along its top edge, so only the bottom moves.

Options

Add any of these attributes to the same element.

data-strengthHow hard the breeze blows. Default 1.
data-gustsSeconds between gusts, give or take. Default 3.2.
data-fromleft or right: where the gusts come from. Default left.
data-tape="false"Leave out the tape strips.
data-pointer="false"Don’t react to the mouse or a finger.

Between gusts the notes barely stir, so text on them stays easy to read. It only runs while on screen, and with reduced motion the notes lie flat.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sticky Notes Flutter: sticky notes on a board lift and flutter as if a
    // breeze were blowing through the room. Gusts roll across the board from
    // one side, catching each note in turn, and the notes shade and cast a
    // bigger shadow as their bottom edge lifts. Move a mouse or drag a finger
    // over the board and the notes nearby flap in its wake.
    // Usage: <div data-sticky-notes-flutter>
    //          <div class="note">Call Sam</div> <div class="note">Buy milk</div> …
    //        </div>
    // The children are the notes; style them as you like. Each one hangs from
    // a strip of tape along its top edge.
    // Optional: data-strength (how hard the breeze blows, default 1),
    // data-gusts (seconds between gusts, default 3.2), data-from ("left",
    // default, or "right"), data-tape="false" (no tape strips), data-pointer="false"
    // (don't react to the mouse or a finger).
    var DEFAULTS = { strength: 1, gusts: 3.2 };

    var css =
      '.skn{position:relative;-webkit-tap-highlight-color:transparent}' +
      '.skn-note{position:relative;transform-origin:50% 0;will-change:transform}' +
      '.skn-shade{position:absolute;inset:0;border-radius:inherit;background:linear-gradient(rgba(0,0,0,0) 30%,rgba(60,40,0,.35));opacity:0;pointer-events:none}' +
      '.skn-tape{position:absolute;left:50%;top:-7px;width:42%;max-width:56px;height:15px;translate:-50% 0;rotate:-3deg;background:rgba(255,255,255,.55);box-shadow:0 1px 2px rgba(0,0,0,.12);pointer-events:none;z-index:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // A smooth bump: 0 far from x, up to 1 at x, with a sharp front and a slow tail.
    function gust(x) {
      if (x < -0.25 || x > 1.6) return 0;
      if (x < 0) return Math.pow(1 + x / 0.25, 2) * 1;
      return Math.exp(-x * 2.6);
    }

    function setup(el) {
      var d = el.dataset;
      var notes = Array.prototype.slice.call(el.children);
      if (!notes.length) return;
      el.classList.add('skn');
      var list = notes.map(function (n, i) {
        n.classList.add('skn-note');
        var shade = document.createElement('span');
        shade.className = 'skn-shade';
        shade.setAttribute('aria-hidden', 'true');
        n.appendChild(shade);
        if (d.tape !== 'false') {
          var tape = document.createElement('span');
          tape.className = 'skn-tape';
          tape.setAttribute('aria-hidden', 'true');
          n.appendChild(tape);
        }
        // Each note has its own slight lean, stiffness and rhythm.
        return {
          el: n, shade: shade, tilt: ((i * 53) % 7 - 3) * 0.9,
          phase: i * 1.7, freq: 5 + (i * 37 % 5) * 0.6, stiff: 0.8 + (i * 29 % 5) * 0.08,
          x: 0, y: 0, w: 0, h: 0, lift: 0, push: 0
        };
      });
      var s = el._sknS = { notes: list, t: 0, last: 0, raf: 0, visible: false, gusts: [], next: 0.6, px: -1, py: -1, pv: 0 };
      var strength = d.strength !== undefined ? +d.strength : DEFAULTS.strength;
      var every = (+d.gusts || DEFAULTS.gusts);
      var fromRight = d.from === 'right';

      function measure() {
        var r = el.getBoundingClientRect();
        list.forEach(function (n) {
          var b = n.el.getBoundingClientRect();
          n.x = (b.left + b.width / 2 - r.left) / (r.width || 1);
          n.cx = b.left + b.width / 2 - r.left;
          n.cy = b.top + b.height / 2 - r.top;
          n.h = b.height;
        });
      }

      function draw() {
        list.forEach(function (n) {
          var lift = Math.max(0, n.lift), side = n.push;
          n.el.style.transform = 'perspective(500px) rotate(' + (n.tilt + side * 4).toFixed(2) + 'deg) rotateX(' + (lift * 48).toFixed(2) + 'deg) skewX(' + (side * 3).toFixed(2) + 'deg)';
          n.shade.style.opacity = Math.min(1, lift * 1.6).toFixed(3);
          n.el.style.boxShadow = '0 ' + (2 + lift * 14).toFixed(1) + 'px ' + (4 + lift * 22).toFixed(1) + 'px rgba(40,25,0,' + (0.18 + lift * 0.12).toFixed(3) + ')';
        });
      }

      function frame(now) {
        s.raf = 0;
        if (!s.visible) return;
        var dt = s.last ? Math.min(0.05, (now - s.last) / 1000) : 0;
        s.last = now;
        s.t += dt;
        // Send a new gust across now and then.
        if (s.t > s.next) {
          s.gusts.push({ t0: s.t, power: 0.55 + Math.random() * 0.6, speed: 0.8 + Math.random() * 0.5 });
          s.next = s.t + every * (0.7 + Math.random() * 0.6);
        }
        s.gusts = s.gusts.filter(function (g) { return s.t - g.t0 < 4; });
        s.pv *= Math.pow(0.04, dt);
        list.forEach(function (n) {
          var pos = fromRight ? 1 - n.x : n.x;
          var wind = 0;
          s.gusts.forEach(function (g) { wind += g.power * gust((s.t - g.t0) * g.speed - pos); });
          // The pointer stirs the air close to it.
          if (s.pv > 0.01 && s.px > -1) {
            var dx = n.cx - s.px, dy = n.cy - s.py;
            var near = Math.exp(-(dx * dx + dy * dy) / (2 * Math.pow(Math.max(60, n.h * 1.3), 2)));
            wind += near * Math.min(1.2, s.pv);
          }
          wind *= strength;
          // A flutter that grows with the wind, on top of a faint idle sway.
          var flap = Math.sin(s.t * n.freq + n.phase) * 0.5 + Math.sin(s.t * n.freq * 2.3 + n.phase * 0.7) * 0.25;
          var target = 0.04 + 0.03 * Math.sin(s.t * 0.9 + n.phase) + wind * (0.62 + flap * 0.45);
          n.lift += (target - n.lift) * Math.min(1, dt * 9 * n.stiff);
          var sideTarget = (fromRight ? -1 : 1) * wind * 0.5 + flap * wind * 0.25;
          n.push += (sideTarget - n.push) * Math.min(1, dt * 6);
        });
        draw();
        s.raf = requestAnimationFrame(frame);
      }

      measure();
      draw();
      new ResizeObserver(measure).observe(el);
      if (reduced()) return;

      if (d.pointer !== 'false') {
        var move = function (e) {
          var r = el.getBoundingClientRect();
          var x = e.clientX - r.left, y = e.clientY - r.top;
          if (s.px > -1) {
            var v = Math.sqrt(Math.pow(x - s.px, 2) + Math.pow(y - s.py, 2));
            s.pv = Math.min(1.4, s.pv + v / 160);
          }
          s.px = x; s.py = y;
        };
        el.addEventListener('pointermove', move);
        el.addEventListener('pointerdown', function (e) { move(e); s.pv = Math.max(s.pv, 0.8); });
        el.addEventListener('pointerleave', function () { s.px = -1; });
      }
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible) { measure(); if (!s.raf) { s.last = 0; s.raf = requestAnimationFrame(frame); } }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-sticky-notes-flutter]').forEach(function (el) {
        if (el._skn) return;
        el._skn = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sticky-notes-flutter class="board">
  <div class="note">Call Sam back</div>
  <div class="note">Buy oat milk</div>
  <div class="note">Ship it on Friday!</div>
</div>