From a84e8d0b6b78ea9a31e225ac8c2f7a5725d1e6a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 17:37:05 +0000 Subject: [PATCH 1/7] Add log 14: blend modes math glossary Concise per-channel formulas for the common layer blend modes (multiply, screen, overlay, soft light, etc.), framed for shader work in linear space. --- content/logs/14-blend-modes-math.mdx | 98 ++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 content/logs/14-blend-modes-math.mdx diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx new file mode 100644 index 00000000..fcd991d4 --- /dev/null +++ b/content/logs/14-blend-modes-math.mdx @@ -0,0 +1,98 @@ +--- +title: 14 - Blend Modes Math +description: Quick glossary of the math behind common layer blend modes, for shader work. +author: 'joyboy' +--- + +## Setup + +Pick two pixels. `a` is the base (what's already there), `b` is the blend (the layer on top). Both channels are floats in `[0, 1]`, **in linear space** (sRGB math is already wrong, see [10 - Color Spaces](/logs/10-color-spaces-srgb-linear)). + +A blend mode is just a per-channel function `f(a, b) → result`. The names are conventions, the formulas are the truth. + +## The cheatsheet + +### Multiply — `a * b` + +Anything in `[0, 1]` multiplied stays in `[0, 1]` and only goes down. White leaves `a` unchanged, black crushes to zero. Shadows, tinting, baking AO onto base color. + +### Screen — `1 - (1 - a) * (1 - b)` + +Multiply in inverse-space: flip, multiply, flip back. Black is the no-op, white blows out. Highlights, bloom, additive light that doesn't clip as fast as `a + b`. + +### Add / Linear Dodge — `a + b` + +The most literal "add light." Unbounded, clips above `1`. Emissives, particles, glow. + +### Subtract — `a - b` + +Strip light. Clamp to `0`. + +### Difference — `|a - b|` + +Equal pixels go black, opposites go white. Debug visualizer, XOR-ish tricks. + +### Darken / Lighten — `min(a, b)` / `max(a, b)` + +Channel-wise min/max. Cheap clamp between two passes. + +### Overlay — `a < 0.5 ? 2*a*b : 1 - 2*(1 - a)*(1 - b)` + +Multiply on the dark half of `a`, screen on the bright half. `a = 0.5` is the no-op. **Hard Light** is the same formula with `a` and `b` swapped — the blend picks the branch instead of the base. + +### Soft Light (W3C variant) + +``` +if (b <= 0.5) a - (1 - 2*b) * a * (1 - a) +else a + (2*b - 1) * (g(a) - a) + +g(a) = a <= 0.25 ? ((16*a - 12)*a + 4)*a : sqrt(a) +``` + +Same intent as overlay, gentler curve, no clipping. There is no single "soft light" — Photoshop, W3C, and Pegtop all differ. Pick one and document it. + +### Color Dodge / Color Burn — `a / (1 - b)` / `1 - (1 - a) / b` + +Division-based brighten/darken. Aggressive: blows up near the singularity. Always clamp. + +### Linear Burn / Linear Light — `a + b - 1` / `a + 2*b - 1` + +Linear versions of burn / dodge-burn. Clamp to `[0, 1]`. + +### Exclusion — `a + b - 2*a*b` + +Smoother difference (no kink at `a = b`). Mid-grey on anything yields mid-grey. + +## Patterns + +- **Multiply ↔ Screen** are duals: `screen(a, b) = 1 - multiply(1 - a, 1 - b)`. Anything you darken in straight space you can brighten by flipping, multiplying, flipping back. +- **Overlay ↔ Hard Light** are the same formula with arguments swapped. +- **Difference ↔ Exclusion** are the same idea, exclusion is the differentiable version. +- **Add / Subtract** ignore the `[0, 1]` contract. Feature in HDR, bug in 8-bit. + +## Opacity + +A blend mode produces a candidate `c = f(a, b)`. Top-layer opacity `α` is a final lerp against the base: + +``` +result = mix(a, c, α) // a*(1 - α) + c*α +``` + +Not `mix(a, b, α)`. The formula runs at full strength first, then opacity dials it back. Easy to get wrong. + +## In a shader + +```glsl +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 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 blendWithAlpha(vec3 base, vec3 candidate, float alpha) { + return mix(base, candidate, alpha); +} +``` + +That's the whole API. Every Photoshop layer blend is one of these (or a combination) running per channel, in linear space, with an opacity lerp on top. From 8c3ff05360a41e9402698b80c6f8e9124affefe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 17:51:53 +0000 Subject: [PATCH 2/7] Rewrite log 14 to match site voice and formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure as: short intro framing what a blend mode is, contract section (linear space + opacity-lerp gotcha), reference table of formulas, three "duals to memorize" subsections, single GLSL block, and a list of common mistakes. Voice/components aligned with logs 10–13. --- content/logs/14-blend-modes-math.mdx | 139 ++++++++++++++++----------- 1 file changed, 84 insertions(+), 55 deletions(-) diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx index fcd991d4..7f391d3c 100644 --- a/content/logs/14-blend-modes-math.mdx +++ b/content/logs/14-blend-modes-math.mdx @@ -1,98 +1,127 @@ --- title: 14 - Blend Modes Math -description: Quick glossary of the math behind common layer blend modes, for shader work. +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' --- -## Setup +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. -Pick two pixels. `a` is the base (what's already there), `b` is the blend (the layer on top). Both channels are floats in `[0, 1]`, **in linear space** (sRGB math is already wrong, see [10 - Color Spaces](/logs/10-color-spaces-srgb-linear)). +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. -A blend mode is just a per-channel function `f(a, b) → result`. The names are conventions, the formulas are the truth. +## The contract -## The cheatsheet +Two assumptions hold for every formula below: -### Multiply — `a * b` +- 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. -Anything in `[0, 1]` multiplied stays in `[0, 1]` and only goes down. White leaves `a` unchanged, black crushes to zero. Shadows, tinting, baking AO onto base color. +A blend mode produces a _candidate_ color. **Opacity is applied separately**, as a final lerp toward the base: -### Screen — `1 - (1 - a) * (1 - b)` - -Multiply in inverse-space: flip, multiply, flip back. Black is the no-op, white blows out. Highlights, bloom, additive light that doesn't clip as fast as `a + b`. - -### Add / Linear Dodge — `a + b` - -The most literal "add light." Unbounded, clips above `1`. Emissives, particles, glow. - -### Subtract — `a - b` - -Strip light. Clamp to `0`. - -### Difference — `|a - b|` +```glsl +vec3 c = blend(a, b); // the formula +vec3 result = mix(a, c, alpha); // a * (1 - alpha) + c * alpha +``` -Equal pixels go black, opposites go white. Debug visualizer, XOR-ish tricks. +Not `mix(a, b, alpha)`. The blend runs at full strength first, then opacity dials it back. People get this wrong all the time. -### Darken / Lighten — `min(a, b)` / `max(a, b)` +## The formulas -Channel-wise min/max. Cheap clamp between two passes. +| 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 = 0.5` is the no-op. | +| **Hard Light** | Overlay with `a` and `b` swapped | The blend, not the base, picks the branch. | +| **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`. | -### Overlay — `a < 0.5 ? 2*a*b : 1 - 2*(1 - a)*(1 - b)` +That's the whole vocabulary. Everything fancier (Vivid Light, Pin Light, Hard Mix) is a piecewise composition of two of these. -Multiply on the dark half of `a`, screen on the bright half. `a = 0.5` is the no-op. **Hard Light** is the same formula with `a` and `b` swapped — the blend picks the branch instead of the base. +## Reading the math -### Soft Light (W3C variant) +Three relationships are worth memorizing because they mean you don't need to look most of these up. -``` -if (b <= 0.5) a - (1 - 2*b) * a * (1 - a) -else a + (2*b - 1) * (g(a) - a) +### Multiply and Screen are duals -g(a) = a <= 0.25 ? ((16*a - 12)*a + 4)*a : sqrt(a) +```text +screen(a, b) = 1 - multiply(1 - a, 1 - b) ``` -Same intent as overlay, gentler curve, no clipping. There is no single "soft light" — Photoshop, W3C, and Pegtop all differ. Pick one and document it. - -### Color Dodge / Color Burn — `a / (1 - b)` / `1 - (1 - a) / b` - -Division-based brighten/darken. Aggressive: blows up near the singularity. Always clamp. - -### Linear Burn / Linear Light — `a + b - 1` / `a + 2*b - 1` +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. -Linear versions of burn / dodge-burn. Clamp to `[0, 1]`. +### Overlay is multiply-then-screen, gated by the base -### Exclusion — `a + b - 2*a*b` +For each pixel, overlay asks "is `a` in the dark half or the bright half?" and runs multiply or screen accordingly. Mid-grey on the base does nothing. That's what makes overlay good for contrast: shadows get darker, highlights get brighter, midtones stay put. -Smoother difference (no kink at `a = b`). Mid-grey on anything yields mid-grey. +### Hard Light is overlay with swapped roles -## Patterns +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. -- **Multiply ↔ Screen** are duals: `screen(a, b) = 1 - multiply(1 - a, 1 - b)`. Anything you darken in straight space you can brighten by flipping, multiplying, flipping back. -- **Overlay ↔ Hard Light** are the same formula with arguments swapped. -- **Difference ↔ Exclusion** are the same idea, exclusion is the differentiable version. -- **Add / Subtract** ignore the `[0, 1]` contract. Feature in HDR, bug in 8-bit. +### Soft Light has no canonical formula -## Opacity +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: -A blend mode produces a candidate `c = f(a, b)`. Top-layer opacity `α` is a final lerp against the base: - -``` -result = mix(a, c, α) // a*(1 - α) + c*α +```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); +} ``` -Not `mix(a, b, α)`. The formula runs at full strength first, then opacity dials it back. Easy to get wrong. +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 -```glsl -vec3 multiply(vec3 a, vec3 b) { return a * b; } -vec3 screen(vec3 a, vec3 b) { return 1.0 - (1.0 - a) * (1.0 - b); } +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); } ``` -That's the whole API. Every Photoshop layer blend is one of these (or a combination) running per channel, in linear space, with an opacity lerp on top. +`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. From 39b4af5c96caaf7d78788a4385a8b7fd75ff0c11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 17:56:30 +0000 Subject: [PATCH 3/7] Fix log 14 author to JOYBOY-0 (matches existing repo handle) --- content/logs/14-blend-modes-math.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx index 7f391d3c..e211248c 100644 --- a/content/logs/14-blend-modes-math.mdx +++ b/content/logs/14-blend-modes-math.mdx @@ -1,7 +1,7 @@ --- 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' +author: 'JOYBOY-0' --- 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. From baa79819665240ee9f732936f824311d8a26770e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 18:01:53 +0000 Subject: [PATCH 4/7] Add interactive blend modes demo to log 14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12-mode gallery using a shared horizontal black→white gradient as the base, so each cell reads left-to-right as the formula evaluated across the full luminance range. Top layer is switchable between solid, RGB bars, and a vertical color gradient — easier to predict and compare than MDN's static photo grid. Each card shows the mode name plus a short formula caption. --- content/logs/14-blend-modes-math.mdx | 8 ++ demos/blend-modes-demo.tsx | 135 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 demos/blend-modes-demo.tsx diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx index e211248c..58af24f9 100644 --- a/content/logs/14-blend-modes-math.mdx +++ b/content/logs/14-blend-modes-math.mdx @@ -4,6 +4,8 @@ description: The math behind layer blend modes (multiply, screen, overlay, dodge 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. @@ -46,6 +48,12 @@ Not `mix(a, b, alpha)`. The blend runs at full strength first, then opacity dial 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. diff --git a/demos/blend-modes-demo.tsx b/demos/blend-modes-demo.tsx new file mode 100644 index 00000000..50b1b2a9 --- /dev/null +++ b/demos/blend-modes-demo.tsx @@ -0,0 +1,135 @@ +'use client' + +import * as React from 'react' +import { cn } from '@/lib/utils' + +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 with a, b swapped' }, + { 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 PRESETS: Record< + PresetKey, + { label: string; render: () => React.ReactNode } +> = { + solid: { + label: 'Solid', + render: () => ( +
+ ), + }, + bars: { + label: 'RGB bars', + render: () => ( +
+
+
+
+
+ ), + }, + gradient: { + label: 'Gradient', + render: () => ( +
+ ), + }, +} + +function BlendModesDemo() { + const [preset, setPreset] = React.useState('gradient') + + return ( +
+
+ + Top layer + + {(Object.keys(PRESETS) as PresetKey[]).map((key) => ( + + ))} +
+ +
+ {MODES.map((mode) => ( +
+
+ {/* base layer: horizontal grayscale ramp (a = 0 → 1) */} +
+ {/* blend layer */} +
+ {PRESETS[preset].render()} +
+
+
+ {mode.name} + + {mode.formula} + +
+
+ ))} +
+ +

+ Base (a) is a horizontal black→white + gradient, so each row reads left-to-right as the formula evaluated at{' '} + a = 0 through{' '} + a = 1. Top layer is{' '} + b. +

+
+ ) +} + +export default BlendModesDemo From 01dbee7d53e7c598d35073853963e96a14c12b3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 18:07:57 +0000 Subject: [PATCH 5/7] Fix overlay/hard-light no-op condition in log 14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-op condition is when the blend is 50% grey (b = 0.5), not the base. With a = 0.5 both branches collapse to b — passthrough of blend, not no-op. Hard Light also has its no-op at b = 0.5 per W3C spec (matches Photoshop's "50% grey layer = no effect"). --- content/logs/14-blend-modes-math.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/logs/14-blend-modes-math.mdx b/content/logs/14-blend-modes-math.mdx index 58af24f9..515ff973 100644 --- a/content/logs/14-blend-modes-math.mdx +++ b/content/logs/14-blend-modes-math.mdx @@ -39,8 +39,8 @@ Not `mix(a, b, alpha)`. The blend runs at full strength first, then opacity dial | **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 = 0.5` is the no-op. | -| **Hard Light** | Overlay with `a` and `b` swapped | The blend, not the base, picks the branch. | +| **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. | @@ -68,7 +68,7 @@ Anything you can do darkening in straight space, you can do brightening by flipp ### 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. Mid-grey on the base does nothing. That's what makes overlay good for contrast: shadows get darker, highlights get brighter, midtones stay put. +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 From 23fabafd4a9075e2398e2f26076ab52f7b2604f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 18:09:31 +0000 Subject: [PATCH 6/7] Rebrand and optimize blend modes demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from stacked DOM nodes with mix-blend-mode to a single element per cell using background-blend-mode — one paint op per cell instead of forcing the browser to create a stacking context per overlay. Wrap the demo in isolation: isolate so the blend math doesn't reach into the surrounding page composition. Add contain: paint per cell. Styling: drop rounded corners on the gallery (matches registry preview which uses sharp corners), use the project's Button component for the preset toggle, mono uppercase labels with the same vocabulary as image-sequence-demo and spritesheet-sequencer-demo. --- demos/blend-modes-demo.tsx | 101 +++++++++++++++---------------------- 1 file changed, 41 insertions(+), 60 deletions(-) diff --git a/demos/blend-modes-demo.tsx b/demos/blend-modes-demo.tsx index 50b1b2a9..3ca73252 100644 --- a/demos/blend-modes-demo.tsx +++ b/demos/blend-modes-demo.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' type Mode = { name: string @@ -18,7 +18,11 @@ const MODES: Mode[] = [ { 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 with a, b swapped' }, + { + 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' }, @@ -26,93 +30,70 @@ const MODES: Mode[] = [ type PresetKey = 'solid' | 'bars' | 'gradient' -const PRESETS: Record< - PresetKey, - { label: string; render: () => React.ReactNode } -> = { +const BASE_GRADIENT = 'linear-gradient(90deg, #000 0%, #fff 100%)' + +const PRESETS: Record = { solid: { label: 'Solid', - render: () => ( -
- ), + image: 'linear-gradient(0deg, #ff2d8a, #ff2d8a)', }, bars: { label: 'RGB bars', - render: () => ( -
-
-
-
-
- ), + image: + 'linear-gradient(180deg, #ff2d2d 0%, #ff2d2d 33.33%, #2dd96b 33.34%, #2dd96b 66.66%, #2d7dff 66.67%, #2d7dff 100%)', }, gradient: { label: 'Gradient', - render: () => ( -
- ), + image: 'linear-gradient(180deg, #00e5ff 0%, #ffe600 50%, #ff2d8a 100%)', }, } function BlendModesDemo() { const [preset, setPreset] = React.useState('gradient') + const backgroundImage = `${PRESETS[preset].image}, ${BASE_GRADIENT}` + return ( -
+
- + Top layer {(Object.keys(PRESETS) as PresetKey[]).map((key) => ( - + ))}
-
+
{MODES.map((mode) => (
-
- {/* base layer: horizontal grayscale ramp (a = 0 → 1) */} -
- {/* blend layer */} -
- {PRESETS[preset].render()} -
-
+
- {mode.name} + + {mode.name} + {mode.formula} @@ -123,10 +104,10 @@ function BlendModesDemo() {

Base (a) is a horizontal black→white - gradient, so each row reads left-to-right as the formula evaluated at{' '} + gradient — each cell reads left-to-right as the formula evaluated at{' '} a = 0 through{' '} - a = 1. Top layer is{' '} - b. + a = 1. Top layer ( + b) is the toggle above.

) From 3e24450d0077467cb853b76fa8555c3f5b24d3fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 18:33:19 +0000 Subject: [PATCH 7/7] Restyle blend modes demo: project Tabs + breathing room - Swap the bespoke button strip for the project's Tabs/TabsList/ TabsTrigger (mono uppercase, matches the registry's tab styling). - Drop the card chrome and hairline-border grid; cells are now bare preview + label, with gap-x-5 gap-y-7 between them. Matches the spacing pattern in image-sequence-demo and spritesheet-sequencer-demo. --- demos/blend-modes-demo.tsx | 49 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/demos/blend-modes-demo.tsx b/demos/blend-modes-demo.tsx index 3ca73252..b463937e 100644 --- a/demos/blend-modes-demo.tsx +++ b/demos/blend-modes-demo.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Button } from '@/components/ui/button' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' type Mode = { name: string @@ -18,11 +18,7 @@ const MODES: Mode[] = [ { 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: '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' }, @@ -48,6 +44,8 @@ const PRESETS: Record = { }, } +const PRESET_KEYS = Object.keys(PRESETS) as PresetKey[] + function BlendModesDemo() { const [preset, setPreset] = React.useState('gradient') @@ -55,31 +53,32 @@ function BlendModesDemo() { return (
-
- +
+ Top layer - {(Object.keys(PRESETS) as PresetKey[]).map((key) => ( - - ))} + setPreset(v as PresetKey)} + > + + {PRESET_KEYS.map((key) => ( + + {PRESETS[key].label} + + ))} + +
-
+
{MODES.map((mode) => (
-
+
{mode.name} - + {mode.formula}
@@ -102,7 +101,7 @@ function BlendModesDemo() { ))}
-

+

Base (a) is a horizontal black→white gradient — each cell reads left-to-right as the formula evaluated at{' '} a = 0 through{' '}