Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Seam transitions between sections" for my site using the Enrichr Library element at https://enrichr.dev/elements/seam-transitions.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/seam-transitions.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- seam: crumble
- accent: #e6ff55
- size: 28
- reach: 30
- title: Where sections meet
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/seam-transitions.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-sm">
<svg class="ef-sm__defs" width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="efsm-pp1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol>
<symbol id="efsm-pp4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
<symbol id="efsm-pp8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
</svg>
<div class="ef-sm__wrap">
<div class="ef-sm__bar" role="group" aria-label="Seam transition">
<button type="button" class="ef-sm__tab" data-seam="crumble" aria-pressed="true">Crumble</button>
<button type="button" class="ef-sm__tab" data-seam="fray" aria-pressed="false">Fray</button>
<button type="button" class="ef-sm__tab" data-seam="craquelure" aria-pressed="false">Craquelure</button>
<button type="button" class="ef-sm__tab" data-seam="ink" aria-pressed="false">Ink</button>
</div>
<div class="ef-sm__scroll" tabindex="0" role="region" aria-label="Scrollable demo. Scroll to play the seam between the two sections.">
<div class="ef-sm__doc">
<section class="ef-sm__hero">
<svg class="ef-sm__art ef-sm__art--a" viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efsm-pp1" width="322.3" height="322.3"/></svg>
<svg class="ef-sm__art ef-sm__art--b" viewBox="0 0 503.07 322.3" aria-hidden="true"><use href="#efsm-pp4" width="503.07" height="322.3"/></svg>
<svg class="ef-sm__art ef-sm__art--c" viewBox="0 0 322.3 503.06" aria-hidden="true"><use href="#efsm-pp8" width="322.3" height="503.06"/></svg>
<div class="ef-sm__copy">
<span class="ef-sm__kick">Enrichr Library</span>
<h3 class="ef-sm__title">Where sections meet</h3>
<span class="ef-sm__cue">Scroll</span>
</div>
</section>
<canvas class="ef-sm__seam" aria-hidden="true"></canvas>
<section class="ef-sm__page">
<h4 class="ef-sm__h">The page underneath</h4>
<p class="ef-sm__p">The edge between two sections does not have to be a straight line. Scroll up and the hero gives way to the page.</p>
<div class="ef-sm__cards">
<div class="ef-sm__card"><svg viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efsm-pp1" width="322.3" height="322.3"/></svg><span>Piece one</span></div>
<div class="ef-sm__card"><svg viewBox="0 0 503.07 322.3" aria-hidden="true"><use href="#efsm-pp4" width="503.07" height="322.3"/></svg><span>Piece two</span></div>
<div class="ef-sm__card"><svg viewBox="0 0 322.3 503.06" aria-hidden="true"><use href="#efsm-pp8" width="322.3" height="503.06"/></svg><span>Piece three</span></div>
</div>
<p class="ef-sm__p ef-sm__p--end">Everything below the seam is ordinary page content.</p>
</section>
</div>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/SeamTransitions (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com) */
.ef-sm { --ef-h: 24rem; --ef-acc: #e6ff55; --ef-above: 110px; --ef-below: 95px; width: 100%; }
.ef-sm__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-sm__wrap { box-sizing: border-box; width: 100%; padding: 0.7rem; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-sm__bar { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; }
.ef-sm__tab { appearance: none; border: 1px solid rgba(251, 248, 241, 0.28); background: transparent; color: #fbf8f1; font: inherit; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; padding: 0.4rem 0.85rem; border-radius: 999px; cursor: pointer; transition: background 200ms, color 200ms, border-color 200ms; }
.ef-sm__tab:hover { border-color: var(--ef-acc); }
.ef-sm__tab[aria-pressed="true"] { background: var(--ef-acc); border-color: var(--ef-acc); color: #0c1e18; }
.ef-sm__tab:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 2px; }
.ef-sm__scroll { position: relative; height: var(--ef-h); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; border-radius: 0.7rem; background: #fbf8f1; scrollbar-width: thin; scrollbar-color: #2f7d5b rgba(0, 0, 0, 0.08); outline: none; }
.ef-sm__scroll:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc); }
.ef-sm__doc { position: relative; width: 100%; }
.ef-sm__hero { position: relative; box-sizing: border-box; height: var(--ef-h); overflow: hidden; background: radial-gradient(ellipse 60% 34% at 78% 14%, rgba(230, 255, 85, 0.16), transparent 70%), linear-gradient(180deg, #0c1e18, #0a6b46); color: #fbf8f1; }
.ef-sm__copy { position: relative; z-index: 1; padding: 1.3rem 1.4rem 0; max-width: 78%; }
.ef-sm__kick { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ef-acc); }
.ef-sm__title { margin: 0.5rem 0 0; font-size: clamp(1.7rem, 7vw, 2.7rem); line-height: 1; letter-spacing: -0.02em; font-weight: 800; }
.ef-sm__cue { display: inline-block; margin-top: 0.9rem; font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.7; }
.ef-sm__art { position: absolute; color: #fbf8f1; opacity: 0.16; pointer-events: none; }
.ef-sm__art--a { width: 22%; right: 6%; top: 8%; transform: rotate(14deg); color: var(--ef-acc); opacity: 0.4; }
.ef-sm__art--b { width: 30%; right: -4%; top: 30%; transform: rotate(-8deg); }
.ef-sm__art--c { width: 14%; right: 36%; top: 18%; transform: rotate(10deg); opacity: 0.1; }
.ef-sm__seam { position: absolute; left: 0; top: 0; width: 100%; height: 0; pointer-events: none; display: block; }
.ef-sm__page { box-sizing: border-box; min-height: calc(var(--ef-h) * 1.35); padding: calc(var(--ef-below) + 1.6rem) 1.4rem 2rem; color: #0c1e18; background: #fbf8f1; }
.ef-sm__h { margin: 0; font-size: 1.45rem; letter-spacing: -0.01em; font-weight: 800; }
.ef-sm__p { margin: 0.6rem 0 0; max-width: 34ch; font-size: 0.92rem; line-height: 1.5; color: #005236; }
.ef-sm__p--end { margin-top: 1.4rem; }
.ef-sm__cards { display: flex; gap: 0.7rem; margin-top: 1.2rem; }
.ef-sm__card { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.9rem 0.5rem 0.7rem; border-radius: 0.7rem; background: #fff; box-shadow: 0 6px 14px -10px rgba(12, 30, 24, 0.5); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; color: #0c1e18; }
.ef-sm__card svg { height: 3rem; max-width: 80%; color: #2f7d5b; }
@media (prefers-reduced-motion: reduce) { .ef-sm__tab { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/SeamTransitions (MIT) — Copyright (c) 2009 - 2026 [Codrops](https://codrops.com)
// Seam transitions: the edge between a hero and the page below it comes apart as you scroll. Four seams on one 2D canvas that
// straddles the edge: Crumble (square tiles break off and fall), Fray (threads pulled loose from the hem), Craquelure (the hero
// cracks into plates, with gold-lime seams) and Ink (the hero bleeds down onto the page). A vanilla port: no GSAP or ScrollSmoother.
// The demo scrolls inside its own container; every frame is a pure function of scrollTop, so scrolling back puts everything back.
// mount(root, { seam, accent, size (px), reach (% of height), title })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var scroller = root.querySelector(".ef-sm__scroll");
var doc = root.querySelector(".ef-sm__doc");
var canvas = root.querySelector(".ef-sm__seam");
var titleEl = root.querySelector(".ef-sm__title");
var tabs = [].slice.call(root.querySelectorAll(".ef-sm__tab"));
var host = root.querySelector(".ef-sm");
var ctx = canvas.getContext("2d");
var opts = {}, raf = 0, ro = null, offs = [];
var W = 0, H = 0, above = 0, below = 0, dpr = 1, geo = null, geoKey = "";
var sway = 0, swayV = 0, lastTop = 0, started = false;
var PAPER = "#fbf8f1", TOP = [12, 30, 24], BOT = [10, 107, 70], DEEP = "#005236";
function rng(seed) { var a = seed >>> 0; return function () { a = (a + 0x6D2B79F5) >>> 0; var t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function ease(t) { return t * t * (3 - 2 * t); }
function rgb(c, k) { k = k || 1; return "rgb(" + Math.round(c[0] * k) + "," + Math.round(c[1] * k) + "," + Math.round(c[2] * k) + ")"; }
function heroCol(y, E, k) { var t = clamp((y - (E - H)) / H, 0, 1); return [TOP[0] + (BOT[0] - TOP[0]) * t, TOP[1] + (BOT[1] - TOP[1]) * t, TOP[2] + (BOT[2] - TOP[2]) * t].map(function (v) { return v * (k || 1); }); }
function heroGrad(E) { var g = ctx.createLinearGradient(0, E - H, 0, E); g.addColorStop(0, rgb(TOP)); g.addColorStop(1, rgb(BOT)); return g; }
// ---- geometry (seeded, rebuilt when the size, width or reach change) ----
function build() {
var key = [opts.seam, W, above, below, opts.size].join("|");
if (key === geoKey) return;
geoKey = key;
var r = rng(7), i, j, g = {};
var S = opts.size;
if (opts.seam === "crumble") {
var cols = Math.max(2, Math.round(W / S)), cs = W / cols, rows = Math.ceil(above / cs);
g.cs = cs; g.cols = cols; g.rows = rows; g.tiles = [];
for (j = 0; j < rows; j++) for (i = 0; i < cols; i++) {
g.tiles.push({ x: i * cs, y: -(j + 1) * cs, j: j, at: 0.03 + 0.62 * ((j + r() * 2.2) / (rows + 2.2)), dx: r() - 0.5, rot: r() - 0.5, shade: 0.88 + r() * 0.22, acc: r() < 0.09 });
}
} else if (opts.seam === "fray") {
var pitch = clamp(S / 3, 5, 14), n = Math.ceil(W / pitch);
g.pitch = pitch; g.threads = [];
var p1 = r() * 6, p2 = r() * 6;
for (i = 0; i < n; i++) g.threads.push({ x: (i + 0.5) * pitch, len: 0.5 * (0.5 + 0.5 * Math.sin(i * pitch * 0.021 + p1) * Math.sin(i * pitch * 0.047 + p2)) + 0.5 * r(), at: r() * 0.2, ph: r() * 6.28, acc: r() < 0.14, w: 0.8 + r() * 0.5 });
} else if (opts.seam === "craquelure") {
var C = S * 1.8, cc = Math.max(2, Math.round(W / C)), cw = W / cc;
var up = Math.ceil(above / cw), dn = Math.ceil(below / cw);
g.cw = cw; g.cc = cc; g.up = up; g.dn = dn;
// vertex grid rows -dn..up, row 0 sits exactly on the edge; vertices are jittered, the edge row only sideways
g.v = [];
for (j = -dn; j <= up; j++) { var row = []; for (i = 0; i <= cc; i++) row.push({ x: i * cw + (i === 0 || i === cc ? 0 : (r() - 0.5) * cw * 0.55), y: -j * cw + (j === 0 ? 0 : (r() - 0.5) * cw * 0.55) }); g.v.push(row); }
g.plates = [];
for (j = 0; j < up + dn; j++) for (i = 0; i < cc; i++) {
var jj = j - dn; // vertex row index of the plate's lower edge
var side = jj >= 0 ? "hero" : "page";
var lvl = side === "hero" ? jj : -jj - 1;
var lim = side === "hero" ? up : dn;
g.plates.push({ i: i, j: j, side: side, at: 0.04 + 0.6 * ((lvl + r() * 1.5) / (lim + 1.5)), rot: r() - 0.5 });
}
} else {
var nd = Math.max(3, Math.round(W / (S * 1.15)));
g.drips = [];
for (i = 0; i < nd; i++) g.drips.push({ x: (i + 0.15 + r() * 0.7) * (W / nd), w: S * (0.22 + r() * 0.3), at: 0.03 + r() * 0.5, max: below * (0.42 + r() * 0.56), drop: r() < 0.55, hl: r() });
}
geo = g;
}
// ---- the four seams. E is the edge's y inside the canvas; s is scrollTop / hero height ----
function crumble(E, s) {
var cs = geo.cs, i, t, f, hs = [], dy, dx, rot, a, space;
ctx.fillStyle = PAPER;
for (i = 0; i < geo.tiles.length; i++) {
t = geo.tiles[i]; f = s - t.at;
if (f <= 0) continue;
ctx.fillRect(t.x - 0.5, E + t.y - 0.5, cs + 1, cs + 1 + (t.j === 0 ? 2 : 0));
hs.push(t);
}
for (i = 0; i < hs.length; i++) {
t = hs[i]; f = s - t.at;
var cx = t.x + cs / 2, cy = E + t.y + cs / 2;
if (reduce) { dx = 0; dy = 0; rot = 0; a = 1 - clamp(f / 0.12, 0, 1); }
else {
dy = f * H * 0.5 + f * f * H * 1.5; dx = t.dx * f * H * 0.7; rot = t.rot * f * 7;
space = (E + below) - cy;
a = 1 - clamp((dy - space * 0.55) / (space * 0.45 + 1), 0, 1);
}
if (a <= 0.01) continue;
ctx.save();
ctx.globalAlpha = a;
ctx.translate(cx + dx, cy + dy);
ctx.rotate(rot);
ctx.fillStyle = t.acc ? opts.accent : rgb(heroCol(cy, E, t.shade));
ctx.fillRect(-cs / 2, -cs / 2, cs, cs);
ctx.restore();
}
}
function fray(E, s) {
var th = geo.threads, pitch = geo.pitch, UP = above * 0.92, pe = ease(clamp(s / 0.9, 0, 1)), i, t, L;
if (pe <= 0) return;
var Ls = [];
ctx.fillStyle = PAPER;
for (i = 0; i < th.length; i++) {
t = th[i]; L = clamp((pe - t.at) / (1 - t.at), 0, 1) * UP * (0.22 + 0.78 * t.len); Ls.push(L);
if (L > 0.4) ctx.fillRect(t.x - pitch / 2 - 0.5, E - L, pitch + 1, L + 2);
}
ctx.lineCap = "round";
for (i = 0; i < th.length; i++) {
t = th[i]; L = Ls[i];
if (L <= pitch * 0.5) continue;
var yt = E - L, tail = L * 0.8, len = L + tail, steps = 9, k, px = t.x, py = yt, nx, ny, u;
var col = t.acc ? opts.accent : rgb(heroCol(yt, E, 0.92));
ctx.strokeStyle = col;
for (k = 1; k <= steps; k++) {
u = k / steps; ny = yt + len * u;
var hang = u * u;
nx = t.x + Math.sin(ny * 0.13 + t.ph) * pitch * 0.16 * u + sway * (reduce ? 0 : 1) * pitch * 1.6 * hang * t.w * (0.6 + 0.4 * Math.sin(t.ph));
ctx.lineWidth = Math.max(0.5, pitch * 0.4 * (1 - u * 0.85));
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(nx, ny); ctx.stroke();
px = nx; py = ny;
}
}
}
function plateRect(a, p) {
var v = geo.v, jr = p.j, i = p.i;
// plate between vertex rows jr (lower on screen, further below) and jr+1
return [v[jr][i], v[jr][i + 1], v[jr + 1][i + 1], v[jr + 1][i]].map(function (q) { return { x: q.x, y: E_ + q.y }; });
}
var E_ = 0;
function craquelure(E, s) {
var P = geo.plates, i, k, p, d, q, c, pts, sc, ang, cs, sn;
E_ = E;
var grad = heroGrad(E);
// page side first: hairline cracks on the cream
ctx.lineJoin = "round";
for (i = 0; i < P.length; i++) {
p = P[i]; if (p.side !== "page") continue;
d = ease(clamp((s - p.at) / 0.3, 0, 1)); if (d <= 0.01) continue;
pts = plateRect(null, p);
var depth = clamp((pts[0].y - E) / Math.max(1, below), 0, 1);
ctx.strokeStyle = "rgba(10,107,70," + (0.55 * d * (1 - depth * 0.7)).toFixed(3) + ")";
ctx.lineWidth = 0.6 + 1.8 * d;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (k = 1; k < 4; k++) ctx.lineTo(pts[k].x, pts[k].y); ctx.closePath(); ctx.stroke();
}
// hero plates: open gap of paper, then the shrunk and turned plate in the hero's own gradient
var live = [];
for (i = 0; i < P.length; i++) {
p = P[i]; if (p.side !== "hero") continue;
d = ease(clamp((s - p.at) / 0.3, 0, 1)); if (d <= 0.004) continue;
pts = plateRect(null, p);
ctx.fillStyle = PAPER;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y + (p.j === geo.dn ? 2 : 0)); for (k = 1; k < 4; k++) ctx.lineTo(pts[k].x, pts[k].y); ctx.closePath(); ctx.fill();
live.push({ p: p, d: d, pts: pts });
}
for (i = 0; i < live.length; i++) {
var L = live[i]; pts = L.pts; d = L.d;
c = { x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4, y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4 };
sc = 1 - 0.2 * d; ang = reduce ? 0 : L.p.rot * 0.14 * d; cs = Math.cos(ang); sn = Math.sin(ang);
ctx.beginPath();
for (k = 0; k < 4; k++) {
var dx = (pts[k].x - c.x) * sc, dy = (pts[k].y - c.y) * sc;
var X = c.x + dx * cs - dy * sn, Y = c.y + dx * sn + dy * cs;
if (k === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);
}
ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
ctx.strokeStyle = opts.accent; ctx.globalAlpha = clamp(d * 1.2, 0, 1); ctx.lineWidth = 1.3; ctx.stroke(); ctx.globalAlpha = 1;
}
}
function ink(E, s) {
var D = geo.drips, i, d, u, len, nw, br, fil, yb, pe = ease(clamp(s / 0.7, 0, 1));
if (s <= 0.005) return;
var g = ctx.createLinearGradient(0, E, 0, E + below);
g.addColorStop(0, rgb(BOT)); g.addColorStop(1, DEEP);
ctx.fillStyle = g;
// a wavering band of ink along the whole hem
var bh = pe * below * 0.09;
ctx.beginPath(); ctx.moveTo(0, E - 1);
for (i = 0; i <= 48; i++) { var x = (W * i) / 48; ctx.lineTo(x, E + bh * (0.55 + 0.45 * Math.sin(x * 0.045 + 1.3) * Math.sin(x * 0.017))); }
ctx.lineTo(W, E - 1); ctx.closePath(); ctx.fill();
for (i = 0; i < D.length; i++) {
d = D[i]; u = clamp((s - d.at) / 0.6, 0, 1); if (u <= 0) continue;
len = (1 - Math.pow(1 - u, 3)) * d.max;
nw = d.w * 0.5; br = d.w * 0.8; fil = nw * 2.4; yb = E + Math.max(len - br, fil);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(d.x - nw - fil, E - 1);
ctx.quadraticCurveTo(d.x - nw, E + 1, d.x - nw, E + fil);
ctx.lineTo(d.x - nw, yb); ctx.lineTo(d.x + nw, yb); ctx.lineTo(d.x + nw, E + fil);
ctx.quadraticCurveTo(d.x + nw, E + 1, d.x + nw + fil, E - 1);
ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.arc(d.x, yb, br * (0.55 + 0.45 * u), 0, 6.2832); ctx.fill();
ctx.strokeStyle = opts.accent; ctx.globalAlpha = 0.55; ctx.lineWidth = 1.2; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(d.x - nw * 0.4, E + fil * 0.9); ctx.lineTo(d.x - nw * 0.4, yb); ctx.stroke(); ctx.globalAlpha = 1;
if (d.drop && u > 0.7) {
var v = (u - 0.7) / 0.3, dy = E + len + br * 0.8 + v * Math.min(below * 0.2, 40), r2 = br * 0.42 * (1 - v * 0.35);
if (dy + r2 < E + below) { ctx.fillStyle = DEEP; ctx.beginPath(); ctx.arc(d.x, dy, r2, 0, 6.2832); ctx.fill(); }
}
}
}
var SEAMS = { crumble: crumble, fray: fray, craquelure: craquelure, ink: ink };
function draw() {
if (!W) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, above + below);
var s = scroller.scrollTop / H;
if (s <= 0.0005 || s > 1 + below / H + 0.05) return;
build();
ctx.save();
SEAMS[opts.seam](above, s);
ctx.restore();
}
function frame() {
raf = 0;
if (opts.seam === "fray" && !reduce) {
swayV += -sway * 0.07; swayV *= 0.9; sway = clamp(sway + swayV, -2, 2);
if (Math.abs(sway) < 0.003 && Math.abs(swayV) < 0.003) { sway = 0; swayV = 0; }
}
draw();
if (opts.seam === "fray" && !reduce && (sway !== 0 || swayV !== 0)) need();
}
function need() { if (!raf) raf = requestAnimationFrame(frame); }
function layout() {
H = scroller.clientHeight;
W = Math.round(doc.clientWidth);
if (!H || !W) return;
above = Math.round(H * opts.reach / 100);
below = Math.round(above * 0.85);
dpr = Math.min(window.devicePixelRatio || 1, 2);
host.style.setProperty("--ef-above", above + "px");
host.style.setProperty("--ef-below", below + "px");
canvas.style.top = (H - above) + "px";
canvas.style.height = (above + below) + "px";
canvas.width = Math.round(W * dpr);
canvas.height = Math.round((above + below) * dpr);
geoKey = "";
if (!started) { started = true; scroller.scrollTop = Math.round(H * 0.5); lastTop = scroller.scrollTop; }
need();
}
function setSeam(name, jump) {
if (!SEAMS[name]) name = "crumble";
opts.seam = name;
tabs.forEach(function (b) { b.setAttribute("aria-pressed", b.getAttribute("data-seam") === name ? "true" : "false"); });
sway = 0; swayV = 0;
if (jump && H) scroller.scrollTo({ top: Math.round(H * 0.5), behavior: reduce ? "auto" : "smooth" });
need();
}
function onScroll() {
var top = scroller.scrollTop;
swayV = clamp(swayV + (top - lastTop) * 0.006, -0.6, 0.6);
lastTop = top;
need();
}
scroller.addEventListener("scroll", onScroll, { passive: true });
offs.push(function () { scroller.removeEventListener("scroll", onScroll); });
tabs.forEach(function (b) {
function ck() { setSeam(b.getAttribute("data-seam"), true); }
b.addEventListener("click", ck);
offs.push(function () { b.removeEventListener("click", ck); });
});
if (window.ResizeObserver) { ro = new ResizeObserver(function () { layout(); }); ro.observe(scroller); }
else { window.addEventListener("resize", layout); offs.push(function () { window.removeEventListener("resize", layout); }); }
function update(next) {
var prev = opts;
opts = Object.assign({ seam: "crumble", accent: "#e6ff55", size: 28, reach: 30, title: "Where sections meet" }, opts, next || {});
opts.size = clamp(Math.round(opts.size), 14, 60);
opts.reach = clamp(Math.round(opts.reach), 16, 42);
host.style.setProperty("--ef-acc", opts.accent);
titleEl.textContent = opts.title;
if (prev.seam !== opts.seam && prev.seam) setSeam(opts.seam, false); else setSeam(opts.seam, false);
if (prev.reach !== opts.reach || !W) layout(); else { geoKey = ""; need(); }
}
update(o);
need();
return {
update: update,
destroy: function () {
if (raf) cancelAnimationFrame(raf);
raf = 0;
if (ro) ro.disconnect();
offs.forEach(function (f) { f(); });
offs = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"seam":"crumble","accent":"#e6ff55","size":28,"reach":30,"title":"Where sections meet"});How it works
A vanilla port of Codrops' Seam Transitions with no GSAP, ScrollTrigger or ScrollSmoother. The demo scrolls inside its own container. One transparent canvas straddles the hero's bottom edge, and on every scroll event the script reads scrollTop, divides it by the hero height and redraws the whole seam as a pure function of that number, so scrolling back puts everything back exactly. Crumble breaks the lowest rows of the hero into square tiles that drop away on a simple falling curve, each one a flat patch of the hero's own gradient, with paper-coloured holes left behind. Fray cuts the hem into thin columns, pulls a thread loose from each and lets a small spring sway them when you scroll fast. Craquelure builds a jittered grid of plates, shrinks and turns them as they dry, and traces each with an accent seam. Ink grows rounded drips with fillets and droplets from the hem. At progress zero every seam draws nothing, so the plain edge shows until you scroll.
Accessibility and performance
The scrolling frame is a focusable region, so the arrow keys, Page Up and Page Down, Home and End all drive the seam. The four seam buttons are real buttons with aria-pressed. The canvas is decorative and hidden from assistive tech. With reduced motion, tiles fade out where they stand instead of falling and plates do not turn or the threads sway; the seam still follows scroll position. Page text always sits below the seam zone, so it is never drawn over.
One 2D canvas, capped at 2x pixel ratio, sized to the seam zone only (about a third of the hero plus the same again below the edge), redrawn at most once per frame and only while that zone is on screen. Geometry is built once per width and setting. There is no rAF loop at rest; Fray runs one only while its sway settles.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven, Reveal and intro |
| Stack | Vanilla JS, CSS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Seam Transitions by Codrops on github.com. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why is every frame a pure function of scroll position?
It keeps the effect reversible and cheap. There is no accumulated state to drift, so scrolling back up restores the edge exactly, and a resize only needs to rebuild the geometry.
How do the falling tiles match the hero they came from?
The hero's background is a known vertical gradient, so each tile is filled with the gradient colour at its own height. Holes are painted in the page colour and reach a couple of pixels past the edge to hide any hairline.
Can I use it on a real page instead of an inner scroller?
Yes. Replace scroller.scrollTop with the window scroll offset minus the hero's top, and listen on window while the hero is on screen. Keep the canvas absolutely positioned over the hero's bottom edge.