diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx
new file mode 100644
index 00000000..515ff973
--- /dev/null
+++ b/content/logs/14-blend-modes-math.mdx
@@ -0,0 +1,135 @@
+---
+title: 14 - Blend Modes Math
+description: The math behind layer blend modes (multiply, screen, overlay, dodge, burn) — what each formula actually does and how to write it in a shader.
+author: 'JOYBOY-0'
+---
+
+import { ComponentPreview } from '@/components/preview/component-preview'
+
+A blend mode is a function. Given two pixels, `a` (the base, what's already there) and `b` (the blend, the layer on top), it returns a third one. That's it. "Multiply", "Screen", "Overlay" are just names for specific tiny formulas, run **per channel**, that the industry agreed on.
+
+Once you have the formulas, every blend mode in Photoshop, Figma, or your favorite shader becomes a one-liner you can read and modify on sight.
+
+## The contract
+
+Two assumptions hold for every formula below:
+
+- Channels are floats in `[0, 1]`. If you're in 8-bit, divide by 255 first.
+- All math happens in **linear space**. Color math in sRGB gives you the wrong answer (see [10 - Color Spaces](/logs/10-color-spaces-srgb-linear)). Decode → blend → encode.
+
+A blend mode produces a _candidate_ color. **Opacity is applied separately**, as a final lerp toward the base:
+
+```glsl
+vec3 c = blend(a, b); // the formula
+vec3 result = mix(a, c, alpha); // a * (1 - alpha) + c * alpha
+```
+
+Not `mix(a, b, alpha)`. The blend runs at full strength first, then opacity dials it back. People get this wrong all the time.
+
+## The formulas
+
+| Mode | Formula | What it does |
+|---|---|---|
+| **Normal** | `b` | Replace the base. |
+| **Multiply** | `a * b` | Darken. White is the no-op, black crushes to zero. |
+| **Screen** | `1 - (1 - a)(1 - b)` | Brighten. Inverse of multiply. Black is the no-op, white blows out. |
+| **Add** (Linear Dodge) | `a + b` | Pure additive light. Unbounded; clips above 1. |
+| **Subtract** | `a - b` | Strip light. Clamp to 0. |
+| **Difference** | `\|a - b\|` | Equal pixels → black, opposites → white. |
+| **Exclusion** | `a + b - 2ab` | Smoother difference (no kink at `a = b`). |
+| **Darken** | `min(a, b)` | Channel-wise minimum. |
+| **Lighten** | `max(a, b)` | Channel-wise maximum. |
+| **Overlay** | `a < 0.5 ? 2ab : 1 - 2(1-a)(1-b)` | Multiply the dark half of `a`, screen the bright half. A 50% grey blend (`b = 0.5`) is the no-op. |
+| **Hard Light** | Overlay with `a` and `b` swapped | The blend, not the base, picks the branch. `b = 0.5` is still the no-op. |
+| **Color Dodge** | `a / (1 - b)` | Aggressive brighten. Always clamp. |
+| **Color Burn** | `1 - (1 - a) / b` | Aggressive darken. Always clamp. |
+| **Linear Burn** | `a + b - 1` | Linear darken. |
+| **Linear Light** | `a + 2b - 1` | Burn for `b < 0.5`, dodge for `b > 0.5`. |
+
+That's the whole vocabulary. Everything fancier (Vivid Light, Pin Light, Hard Mix) is a piecewise composition of two of these.
+
+## See it
+
+The base layer (`a`) in every cell is the same black→white gradient, so each strip reads left-to-right as the formula evaluated from `a = 0` to `a = 1`. Swap the top layer (`b`) to see how the same formula reacts to different content.
+
+
+
+## Reading the math
+
+Three relationships are worth memorizing because they mean you don't need to look most of these up.
+
+### Multiply and Screen are duals
+
+```text
+screen(a, b) = 1 - multiply(1 - a, 1 - b)
+```
+
+Anything you can do darkening in straight space, you can do brightening by flipping inputs, multiplying, then flipping the result. Screen "feels like" multiply with the opposite polarity because it literally is.
+
+### Overlay is multiply-then-screen, gated by the base
+
+For each pixel, overlay asks "is `a` in the dark half or the bright half?" and runs multiply or screen accordingly. A 50% grey **blend** (`b = 0.5`) is the no-op: both branches collapse to `a`. That's why blending an image with itself in overlay mode boosts contrast: shadows get darker, highlights get brighter, and midtones (around `0.5`) barely move.
+
+### Hard Light is overlay with swapped roles
+
+Same formula, but the **blend** picks the branch. Pouring 0.2 grey through hard light multiplies everything. Pouring 0.8 grey screens everything. Useful when you want the top layer's brightness, not the base's, to drive the effect.
+
+### Soft Light has no canonical formula
+
+There are at least three definitions in the wild (Photoshop's, the W3C's, Pegtop's). All target the same intent (overlay-ish but gentler, no clipping at extremes), all give slightly different results. The W3C version is the one most compositing specs reference:
+
+```glsl
+float softLight(float a, float b) {
+ if (b <= 0.5) {
+ return a - (1.0 - 2.0 * b) * a * (1.0 - a);
+ }
+ float g = a <= 0.25
+ ? ((16.0 * a - 12.0) * a + 4.0) * a
+ : sqrt(a);
+ return a + (2.0 * b - 1.0) * (g - a);
+}
+```
+
+If "soft light" matters in your pipeline, pin a specific formula and document which one. Don't assume the artist's reference matches your shader.
+
+## In a shader
+
+The whole library fits in a few lines of GLSL:
+
+```glsl showLineNumbers
+vec3 multiply(vec3 a, vec3 b) { return a * b; }
+vec3 screen(vec3 a, vec3 b) { return 1.0 - (1.0 - a) * (1.0 - b); }
+vec3 add(vec3 a, vec3 b) { return a + b; }
+vec3 subtract(vec3 a, vec3 b) { return max(a - b, 0.0); }
+vec3 difference(vec3 a, vec3 b) { return abs(a - b); }
+vec3 exclusion(vec3 a, vec3 b) { return a + b - 2.0 * a * b; }
+vec3 darken(vec3 a, vec3 b) { return min(a, b); }
+vec3 lighten(vec3 a, vec3 b) { return max(a, b); }
+
+vec3 overlay(vec3 a, vec3 b) {
+ return mix(2.0 * a * b,
+ 1.0 - 2.0 * (1.0 - a) * (1.0 - b),
+ step(0.5, a));
+}
+
+vec3 hardLight(vec3 a, vec3 b) { return overlay(b, a); }
+vec3 colorDodge(vec3 a, vec3 b) { return min(a / max(1.0 - b, 1e-4), 1.0); }
+vec3 colorBurn(vec3 a, vec3 b) { return 1.0 - min((1.0 - a) / max(b, 1e-4), 1.0); }
+vec3 linearBurn(vec3 a, vec3 b) { return max(a + b - 1.0, 0.0); }
+vec3 linearLight(vec3 a, vec3 b) { return clamp(a + 2.0 * b - 1.0, 0.0, 1.0); }
+
+vec3 blendWithAlpha(vec3 base, vec3 candidate, float alpha) {
+ return mix(base, candidate, alpha);
+}
+```
+
+`step(0.5, a)` returns `0` below `0.5` and `1` above, so `mix` with it as the third argument acts as a per-channel `if`. The `max(_, 1e-4)` on the divisions guards the singularity in dodge and burn, otherwise you'll output NaNs the moment a channel of `b` hits 0 or 1.
+
+## Things that go wrong
+
+- **Forgetting to linearize.** sRGB textures need `pow(x, 2.2)` (or the proper sRGB→linear function) before multiplication. Skip this and your "multiply" will look subtly wrong, especially in the midtones.
+- **Applying alpha inside the formula.** `mix(a, b, alpha)` is _alpha compositing_, not blend-mode opacity. Run the blend at full strength first, then mix toward the base.
+- **Reducing to a single brightness value.** Every formula above is **per channel**. Don't compute one luminance and apply it to RGB; you'll lose the color identity of the blend layer.
+- **Skipping the divide guard.** Color Dodge and Color Burn divide. Without a clamp on the denominator you'll output infinities the moment a channel hits the boundary.
+
+Layer blends look mystical in a Photoshop panel. At the pixel level they're a handful of arithmetic operations, run per channel, with an opacity lerp on top.
diff --git a/demos/blend-modes-demo.tsx b/demos/blend-modes-demo.tsx
new file mode 100644
index 00000000..b463937e
--- /dev/null
+++ b/demos/blend-modes-demo.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import * as React from 'react'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
+type Mode = {
+ name: string
+ css: React.CSSProperties['mixBlendMode']
+ formula: string
+}
+
+const MODES: Mode[] = [
+ { name: 'normal', css: 'normal', formula: 'b' },
+ { name: 'multiply', css: 'multiply', formula: 'a · b' },
+ { name: 'screen', css: 'screen', formula: '1 − (1−a)(1−b)' },
+ { name: 'overlay', css: 'overlay', formula: 'multiply / screen on a' },
+ { name: 'darken', css: 'darken', formula: 'min(a, b)' },
+ { name: 'lighten', css: 'lighten', formula: 'max(a, b)' },
+ { name: 'color-dodge', css: 'color-dodge', formula: 'a / (1 − b)' },
+ { name: 'color-burn', css: 'color-burn', formula: '1 − (1−a) / b' },
+ { name: 'hard-light', css: 'hard-light', formula: 'overlay, branch on b' },
+ { name: 'soft-light', css: 'soft-light', formula: 'gentle overlay (W3C)' },
+ { name: 'difference', css: 'difference', formula: '|a − b|' },
+ { name: 'exclusion', css: 'exclusion', formula: 'a + b − 2ab' },
+]
+
+type PresetKey = 'solid' | 'bars' | 'gradient'
+
+const BASE_GRADIENT = 'linear-gradient(90deg, #000 0%, #fff 100%)'
+
+const PRESETS: Record = {
+ solid: {
+ label: 'Solid',
+ image: 'linear-gradient(0deg, #ff2d8a, #ff2d8a)',
+ },
+ bars: {
+ label: 'RGB bars',
+ image:
+ 'linear-gradient(180deg, #ff2d2d 0%, #ff2d2d 33.33%, #2dd96b 33.34%, #2dd96b 66.66%, #2d7dff 66.67%, #2d7dff 100%)',
+ },
+ gradient: {
+ label: 'Gradient',
+ image: 'linear-gradient(180deg, #00e5ff 0%, #ffe600 50%, #ff2d8a 100%)',
+ },
+}
+
+const PRESET_KEYS = Object.keys(PRESETS) as PresetKey[]
+
+function BlendModesDemo() {
+ const [preset, setPreset] = React.useState('gradient')
+
+ const backgroundImage = `${PRESETS[preset].image}, ${BASE_GRADIENT}`
+
+ return (
+
+ Base (a) is a horizontal black→white
+ gradient — each cell reads left-to-right as the formula evaluated at{' '}
+ a = 0 through{' '}
+ a = 1. Top layer (
+ b) is the toggle above.
+