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
- Paste the snippet just before
</head>. - Add
data-sticky-notes-flutterto your board, with the notes as its children. Style the notes as you like. - 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-strength | How hard the breeze blows. Default 1. |
data-gusts | Seconds between gusts, give or take. Default 3.2. |
data-from | left 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.
<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><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>