diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs
index dcf5da1b..370776f3 100644
--- a/packages/core/pattern.mjs
+++ b/packages/core/pattern.mjs
@@ -2392,3 +2392,29 @@ export const ref = (accessor) =>
pure(1)
.withValue(() => reify(accessor()))
.innerJoin();
+
+let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
+
+/**
+ * Cross-fades between left and right from 0 to 1:
+ * - 0 = (full left, no right)
+ * - .5 = (both equal)
+ * - 1 = (no left, full right)
+ *
+ * @name xfade
+ * @example
+ * xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
+ */
+export let xfade = (a, pos, b) => {
+ pos = reify(pos);
+ a = reify(a);
+ b = reify(b);
+ let gaina = pos.fmap((v) => ({ gain: fadeGain(v) }));
+ let gainb = pos.fmap((v) => ({ gain: fadeGain(1 - v) }));
+ return stack(a.mul(gaina), b.mul(gainb));
+};
+
+// the prototype version is actually flipped so left/right makes sense
+Pattern.prototype.xfade = function (pos, b) {
+ return xfade(this, pos, b);
+};
diff --git a/website/src/pages/learn/effects.mdx b/website/src/pages/learn/effects.mdx
index 30804240..b1323a8e 100644
--- a/website/src/pages/learn/effects.mdx
+++ b/website/src/pages/learn/effects.mdx
@@ -156,6 +156,10 @@ There is one filter envelope for each filter type and thus one set of envelope f
+## xfade
+
+
+
# Panning
## jux