diff --git a/r/gradient.json b/r/gradient.json index 82754dd..89c456b 100644 --- a/r/gradient.json +++ b/r/gradient.json @@ -20,7 +20,7 @@ "path": "components/dither-kit/gradient.tsx", "type": "registry:component", "target": "components/dither-kit/gradient.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\nimport {\n BAYER4,\n fillOf,\n type PixelBloom,\n type PixelColor,\n pixelBloomStyle,\n} from \"./pixel\"\n\n// Backing-resolution caps — a background wash never needs more cells than this.\nconst MAX_COLS = 960\nconst MAX_ROWS = 600\n\nexport type GradientDirection = \"up\" | \"down\" | \"left\" | \"right\"\n\nexport type DitherGradientProps = {\n /** The colour the gradient starts solid as — a palette name or a hue. */\n from: PixelColor\n /** What it dissolves into: another colour for a two-tone dither blend, or\n * \"transparent\" (default) so the background shows through. */\n to?: PixelColor | \"transparent\"\n /** Where `to` ends up — \"up\" reads as a glow rising from the bottom edge. */\n direction?: GradientDirection\n /** CSS px per dither cell — bigger is chunkier. */\n cell?: number\n /** Overall opacity multiplier. */\n opacity?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n className?: string\n}\n\ntype PaintSpec = {\n from: PixelColor\n to: PixelColor | \"transparent\"\n direction: GradientDirection\n cell: number\n opacity: number\n}\n\n/**\n * Paint the ordered-dither ramp onto a low-res backing canvas sized from the\n * wrapper's box. Static — one paint per prop/size change, no animation loop,\n * so it's free to use as a page-wide background.\n */\nfunction paintGradient(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n width: number,\n height: number,\n spec: PaintSpec\n): void {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx || width <= 0 || height <= 0) return\n const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell)))\n const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell)))\n canvas.width = cols\n canvas.height = rows\n\n const fromFill = fillOf(spec.from)\n const toFill = spec.to === \"transparent\" ? null : fillOf(spec.to)\n const o = spec.opacity\n\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) {\n // t runs 0 at the `from` edge → 1 at the `to` edge.\n const t =\n spec.direction === \"up\"\n ? 1 - (y + 0.5) / rows\n : spec.direction === \"down\"\n ? (y + 0.5) / rows\n : spec.direction === \"left\"\n ? 1 - (x + 0.5) / cols\n : (x + 0.5) / cols\n const density = 1 - t\n const lit = density > BAYER4[y & 3][x & 3]\n if (toFill) {\n // Two-tone: every cell is painted, the dither decides which colour.\n ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o)\n ctx.fillRect(x, y, 1, 1)\n } else {\n // Dissolve to transparent: lit cells carry the ramp, off cells keep a\n // faint tint that also fades out, so the falloff reads smooth.\n const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o\n if (alpha <= 0.004) continue\n ctx.fillStyle = rgb(fromFill, 1, alpha)\n ctx.fillRect(x, y, 1, 1)\n }\n }\n }\n\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas && bloomCtx) {\n bloomCanvas.width = cols\n bloomCanvas.height = rows\n bloomCtx.drawImage(canvas, 0, 0)\n }\n}\n\n/**\n * Dithered gradient wash — the charts' ordered-dither texture as a background.\n * Fills its nearest positioned ancestor (footer glows, section fades, card\n * backdrops). Dissolves to transparent by default, or dither-blends between\n * two colours when `to` is set.\n */\nexport function DitherGradient({\n from,\n to = \"transparent\",\n direction = \"up\",\n cell = 3,\n opacity = 1,\n bloom = \"off\",\n className,\n}: DitherGradientProps) {\n const wrapRef = useRef(null)\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n useEffect(() => {\n const wrap = wrapRef.current\n const canvas = canvasRef.current\n if (!wrap || !canvas) return\n const paint = () => {\n const box = wrap.getBoundingClientRect()\n paintGradient(canvas, bloomRef.current, box.width, box.height, {\n from,\n to,\n direction,\n cell,\n opacity,\n })\n }\n paint()\n if (typeof ResizeObserver === \"undefined\") return\n const ro = new ResizeObserver(paint)\n ro.observe(wrap)\n return () => ro.disconnect()\n }, [from, to, direction, cell, opacity, bloom])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n \n \n {bloomStyle && (\n \n )}\n \n )\n}\n" + "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { cn } from \"./lib\"\nimport { fillOf, type PixelBloom, type PixelColor, pixelBloomStyle } from \"./pixel\"\n\nexport type GradientDirection = \"up\" | \"down\" | \"left\" | \"right\" | number\n\nexport type DitherGradientProps = {\n /** The colour the gradient starts solid as — a palette name or a hue. */\n from: PixelColor\n /** What it dissolves into: another colour for a two-tone dither blend, or\n * \"transparent\" (default) so the background shows through. */\n to?: PixelColor | \"transparent\"\n /** Where `to` ends up — \"up\" reads as a glow rising from the bottom edge, or a number for a custom angle in degrees. */\n direction?: GradientDirection\n /** CSS px per dither cell — bigger is chunkier. */\n cell?: number\n /** Overall opacity multiplier. */\n opacity?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n /** Number of quantization steps (color levels). Defaults to 6. */\n levels?: number\n className?: string\n}\n\n/**\n * Normalizes the direction prop into a standard angle in degrees.\n * 0° points right, 90° points down, 180° points left, 270° points up.\n */\nfunction getAngle(direction: GradientDirection): number {\n if (typeof direction === \"number\") return direction\n switch (direction) {\n case \"right\":\n return 0\n case \"down\":\n return 90\n case \"left\":\n return 180\n case \"up\":\n return 270\n default:\n return 270\n }\n}\n\n/**\n * Generates a Base64 PNG data URL of the Bayer threshold matrix.\n * We draw the 4x4 matrix onto an in-memory canvas scaled by the `cell` size.\n * This PNG is then fed into the SVG filter chain to act as the threshold map.\n */\nfunction generateBayerDataUrl(n: number, cell: number): string {\n if (typeof window === \"undefined\") return \"\"\n \n // Standard 4x4 Bayer matrix\n const rawMatrix = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n ]\n const n2 = n * n\n \n const canvas = document.createElement(\"canvas\")\n canvas.width = n * cell\n canvas.height = n * cell\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return \"\"\n \n const imgData = ctx.createImageData(canvas.width, canvas.height)\n const data = imgData.data\n \n // Paint each cell as a greyscale block based on its matrix threshold value\n for (let y = 0; y < canvas.height; y++) {\n const by = Math.floor(y / cell) % n\n for (let x = 0; x < canvas.width; x++) {\n const bx = Math.floor(x / cell) % n\n const val = Math.round((rawMatrix[by][bx] / n2) * 255)\n const idx = (y * canvas.width + x) * 4\n data[idx] = val // R\n data[idx + 1] = val // G\n data[idx + 2] = val // B\n data[idx + 3] = 255 // A (fully opaque)\n }\n }\n ctx.putImageData(imgData, 0, 0)\n return canvas.toDataURL(\"image/png\")\n}\n\n/**\n * Dithered gradient wash — the charts' ordered-dither texture as a background.\n * Uses a procedural SVG filter chain (GPU accelerated) to dither between colors\n * or fade to transparency across any 360-degree angle.\n */\nexport function DitherGradient({\n from,\n to = \"transparent\",\n direction = \"up\",\n cell = 3,\n opacity = 1,\n bloom = \"off\",\n levels = 6,\n className,\n}: DitherGradientProps) {\n const wrapRef = useRef(null)\n const [dims, setDims] = useState({ width: 0, height: 0 })\n \n // Unique IDs for SVG defs to prevent collisions when multiple gradients are mounted\n const baseId = useId()\n const cleanId = baseId.replace(/:/g, \"\")\n const gradientId = `g-${cleanId}`\n const filterId = `d-${cleanId}`\n\n // Observe the wrapper's dimensions. We need the exact pixel width/height \n // to correctly project the SVG linear-gradient coordinates (`userSpaceOnUse`).\n useEffect(() => {\n const wrap = wrapRef.current\n if (!wrap) return\n const update = () => {\n const box = wrap.getBoundingClientRect()\n setDims({ width: box.width, height: box.height })\n }\n update()\n if (typeof ResizeObserver === \"undefined\") return\n const ro = new ResizeObserver(update)\n ro.observe(wrap)\n return () => ro.disconnect()\n }, [])\n\n const bayerDataUrl = useMemo(() => {\n return generateBayerDataUrl(4, cell)\n }, [cell])\n\n // Calculate the x1, y1, x2, y2 coordinates for the SVG linearGradient.\n // By projecting the four corners of the container onto the angle vector,\n // we ensure the gradient perfectly stretches corner-to-corner without distortion.\n const { x1, y1, x2, y2 } = useMemo(() => {\n const angle = getAngle(direction)\n const rad = (angle * Math.PI) / 180\n const dx = Math.cos(rad)\n const dy = Math.sin(rad)\n const w = dims.width\n const h = dims.height\n\n if (w === 0 || h === 0) {\n return { x1: 0, y1: 0, x2: 0, y2: 0 }\n }\n\n const corners = [\n [0, 0],\n [w, 0],\n [0, h],\n [w, h],\n ]\n let minP = Infinity\n let maxP = -Infinity\n for (const [cx, cy] of corners) {\n // Dot product to project the corner onto the angle vector\n const p = cx * dx + cy * dy\n if (p < minP) minP = p\n if (p > maxP) maxP = p\n }\n\n return {\n x1: minP * dx,\n y1: minP * dy,\n x2: maxP * dx,\n y2: maxP * dy,\n }\n }, [direction, dims.width, dims.height])\n\n // Color Matrix: Maps the quantized, dithered grayscale values into the final output colors.\n const matrixValues = useMemo(() => {\n const r1 = fillOf(from)\n if (to === \"transparent\") {\n // Dissolve to transparent:\n // Keep R, G, B channels locked to the `from` color.\n // Pipe the dithered grayscale pattern (from the Red channel) into the Alpha channel, scaled by overall opacity.\n return [\n 0, 0, 0, 0, r1[0] / 255,\n 0, 0, 0, 0, r1[1] / 255,\n 0, 0, 0, 0, r1[2] / 255,\n opacity, 0, 0, 0, 0,\n ].join(\" \")\n } else {\n // Two-tone blend:\n // Interpolate R, G, B channels between the `from` and `to` colors based on the dithered grayscale value.\n // Set the Alpha channel to the constant overall opacity.\n const r2 = fillOf(to)\n return [\n (r1[0] - r2[0]) / 255, 0, 0, 0, r2[0] / 255,\n 0, (r1[1] - r2[1]) / 255, 0, 0, r2[1] / 255,\n 0, 0, (r1[2] - r2[2]) / 255, 0, r2[2] / 255,\n 0, 0, 0, 0, opacity,\n ].join(\" \")\n }\n }, [from, to, opacity])\n\n // Calculate threshold step arrays for the discrete component transfer\n const stepsString = useMemo(() => {\n const l = Math.max(2, levels)\n const arr = []\n for (let i = 0; i < l; i++) {\n arr.push((i / (l - 1)).toFixed(4))\n }\n return arr.join(\" \")\n }, [levels])\n\n // Calculate weights for the arithmetic composite step\n // k2 scales the underlying gradient, k3 adds the Bayer matrix noise\n const { k2, k3 } = useMemo(() => {\n const l = Math.max(2, levels)\n return {\n k2: ((l - 1) / l).toFixed(4),\n k3: (1 / l).toFixed(4),\n }\n }, [levels])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n // Avoid rendering filter elements before width/height are measured to prevent SSR layout shifts\n if (dims.width === 0 || dims.height === 0) {\n return
\n }\n\n return (\n \n \n \n {/* Base uniform grayscale gradient */}\n \n \n \n \n\n {/* Core Procedural Filter Chain */}\n \n {/* 1. Import the Base64 Bayer matrix */}\n {bayerDataUrl && (\n \n )}\n {/* 2. Tile the matrix across the entire container */}\n \n \n {/* 3. Mathematically add the tiled matrix noise to the smooth grayscale gradient */}\n \n \n {/* 4. Quantize the result into discrete steps (thresholding) */}\n \n \n \n \n \n \n \n {/* 5. Remap the dithered grayscale pattern into the final colors and alpha */}\n \n \n \n \n {/* Render the actual element using the gradient and filter */}\n \n \n {/* Render a duplicated layer for bloom/glow effects if requested */}\n {bloomStyle && (\n \n )}\n \n
\n )\n}\n" }, { "path": "components/dither-kit/pixel.ts", diff --git a/registry/dither-kit/gradient.tsx b/registry/dither-kit/gradient.tsx index 2eb967b..306a13c 100644 --- a/registry/dither-kit/gradient.tsx +++ b/registry/dither-kit/gradient.tsx @@ -1,21 +1,10 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect, useId, useMemo, useRef, useState } from "react" import { cn } from "./lib" -import { rgb } from "./palette" -import { - BAYER4, - fillOf, - type PixelBloom, - type PixelColor, - pixelBloomStyle, -} from "./pixel" +import { fillOf, type PixelBloom, type PixelColor, pixelBloomStyle } from "./pixel" -// Backing-resolution caps — a background wash never needs more cells than this. -const MAX_COLS = 960 -const MAX_ROWS = 600 - -export type GradientDirection = "up" | "down" | "left" | "right" +export type GradientDirection = "up" | "down" | "left" | "right" | number export type DitherGradientProps = { /** The colour the gradient starts solid as — a palette name or a hue. */ @@ -23,7 +12,7 @@ export type DitherGradientProps = { /** What it dissolves into: another colour for a two-tone dither blend, or * "transparent" (default) so the background shows through. */ to?: PixelColor | "transparent" - /** Where `to` ends up — "up" reads as a glow rising from the bottom edge. */ + /** Where `to` ends up — "up" reads as a glow rising from the bottom edge, or a number for a custom angle in degrees. */ direction?: GradientDirection /** CSS px per dither cell — bigger is chunkier. */ cell?: number @@ -31,81 +20,75 @@ export type DitherGradientProps = { opacity?: number /** Glow on the dither fill. */ bloom?: PixelBloom + /** Number of quantization steps (color levels). Defaults to 6. */ + levels?: number className?: string } -type PaintSpec = { - from: PixelColor - to: PixelColor | "transparent" - direction: GradientDirection - cell: number - opacity: number +/** + * normalizes the direction prop into a standard angle in degrees + */ +function getAngle(direction: GradientDirection): number { + if (typeof direction === "number") return direction + switch (direction) { + case "right": + return 0 + case "down": + return 90 + case "left": + return 180 + case "up": + return 270 + default: + return 270 + } } /** - * Paint the ordered-dither ramp onto a low-res backing canvas sized from the - * wrapper's box. Static — one paint per prop/size change, no animation loop, - * so it's free to use as a page-wide background. + * generates a Base64 png data URL of the Bayer threshold matrix + * draw the 4x4 matrix onto an canvas (that's in-memory) scaled by the cell size + * this png is then feeded into the SVG filter chain to act as the threshold map */ -function paintGradient( - canvas: HTMLCanvasElement, - bloomCanvas: HTMLCanvasElement | null, - width: number, - height: number, - spec: PaintSpec -): void { +function generateBayerData(n: number, cell: number): string { + if (typeof window === "undefined") return "" + + const rawMatrix = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], + ] + const n2 = n * n + + const canvas = document.createElement("canvas") + canvas.width = n * cell + canvas.height = n * cell const ctx = canvas.getContext("2d") - if (!ctx || width <= 0 || height <= 0) return - const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell))) - const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell))) - canvas.width = cols - canvas.height = rows - - const fromFill = fillOf(spec.from) - const toFill = spec.to === "transparent" ? null : fillOf(spec.to) - const o = spec.opacity - - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - // t runs 0 at the `from` edge → 1 at the `to` edge. - const t = - spec.direction === "up" - ? 1 - (y + 0.5) / rows - : spec.direction === "down" - ? (y + 0.5) / rows - : spec.direction === "left" - ? 1 - (x + 0.5) / cols - : (x + 0.5) / cols - const density = 1 - t - const lit = density > BAYER4[y & 3][x & 3] - if (toFill) { - // Two-tone: every cell is painted, the dither decides which colour. - ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o) - ctx.fillRect(x, y, 1, 1) - } else { - // Dissolve to transparent: lit cells carry the ramp, off cells keep a - // faint tint that also fades out, so the falloff reads smooth. - const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o - if (alpha <= 0.004) continue - ctx.fillStyle = rgb(fromFill, 1, alpha) - ctx.fillRect(x, y, 1, 1) - } + if (!ctx) return "" + + const imgData = ctx.createImageData(canvas.width, canvas.height) + const data = imgData.data + + // paint each cell as a greyscale block based on its matrix threshold value + for (let y = 0; y < canvas.height; y++) { + const by = Math.floor(y / cell) % n + for (let x = 0; x < canvas.width; x++) { + const bx = Math.floor(x / cell) % n + const val = Math.round((rawMatrix[by][bx] / n2) * 255) + const idx = (y * canvas.width + x) * 4 + data[idx] = val // R + data[idx + 1] = val // G + data[idx + 2] = val // B + data[idx + 3] = 255 // A (opaque) } } - - const bloomCtx = bloomCanvas?.getContext("2d") ?? null - if (bloomCanvas && bloomCtx) { - bloomCanvas.width = cols - bloomCanvas.height = rows - bloomCtx.drawImage(canvas, 0, 0) - } + ctx.putImageData(imgData, 0, 0) + return canvas.toDataURL("image/png") } /** - * Dithered gradient wash — the charts' ordered-dither texture as a background. - * Fills its nearest positioned ancestor (footer glows, section fades, card - * backdrops). Dissolves to transparent by default, or dither-blends between - * two colours when `to` is set. + * dithered-gradient wash the charts' ordered-dither texture as a background. + * use a procedural SVG filter chain to dither between colors (should be gpu accelerated) */ export function DitherGradient({ from, @@ -114,35 +97,130 @@ export function DitherGradient({ cell = 3, opacity = 1, bloom = "off", + levels = 6, className, }: DitherGradientProps) { const wrapRef = useRef(null) - const canvasRef = useRef(null) - const bloomRef = useRef(null) + const [dims, setDims] = useState({ width: 0, height: 0 }) + + // unique IDs for SVG defs to prevent collisions when multiple gradients are mounted + const baseId = useId() + const cleanId = baseId.replace(/:/g, "") + const gradientId = `g-${cleanId}` + const filterId = `d-${cleanId}` + // need the exact pixel width/height to correctly project the SVG linear gradient coordinates useEffect(() => { const wrap = wrapRef.current - const canvas = canvasRef.current - if (!wrap || !canvas) return - const paint = () => { + if (!wrap) return + const update = () => { const box = wrap.getBoundingClientRect() - paintGradient(canvas, bloomRef.current, box.width, box.height, { - from, - to, - direction, - cell, - opacity, - }) + setDims({ width: box.width, height: box.height }) } - paint() + update() if (typeof ResizeObserver === "undefined") return - const ro = new ResizeObserver(paint) + const ro = new ResizeObserver(update) ro.observe(wrap) return () => ro.disconnect() - }, [from, to, direction, cell, opacity, bloom]) + }, []) + + const bayerDataUrl = useMemo(() => { + return generateBayerData(4, cell) + }, [cell]) + + // calculate the coordinates for the SVG linearGradient. + // projects all corners of the container onto the angle vector, + // the gradient perfectly stretches corner-to-corner without distortion. + const { x1, y1, x2, y2 } = useMemo(() => { + const angle = getAngle(direction) + const rad = (angle * Math.PI) / 180 + const dx = Math.cos(rad) + const dy = Math.sin(rad) + const w = dims.width + const h = dims.height + + if (w === 0 || h === 0) { + return { x1: 0, y1: 0, x2: 0, y2: 0 } + } + + const corners = [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ] + let minP = Infinity + let maxP = -Infinity + for (const [cx, cy] of corners) { + // ot product to project the corner onto the angle vector + const p = cx * dx + cy * dy + if (p < minP) minP = p + if (p > maxP) maxP = p + } + + return { + x1: minP * dx, + y1: minP * dy, + x2: maxP * dx, + y2: maxP * dy, + } + }, [direction, dims.width, dims.height]) + + // Maps the dithered grayscale values (quantized) into the final output colors: + const matrixValues = useMemo(() => { + const r1 = fillOf(from) + if (to === "transparent") { + // dissolve to transparent: + // Keep R, G, B channels locked to the *from* color. + // Pipe the dithered grayscale pattern (from the Red channel) into the Alpha channel + return [ + 0, 0, 0, 0, r1[0] / 255, + 0, 0, 0, 0, r1[1] / 255, + 0, 0, 0, 0, r1[2] / 255, + opacity, 0, 0, 0, 0, // scales by overall opacity + ].join(" ") + } else { + + // Two-tone blend: + // Interpolate R, G, B channels between the `from` and `to` colors based on the dithered grayscale value. + // Set the Alpha channel to the constant overall opacity. + const r2 = fillOf(to) + return [ + (r1[0] - r2[0]) / 255, 0, 0, 0, r2[0] / 255, + 0, (r1[1] - r2[1]) / 255, 0, 0, r2[1] / 255, + 0, 0, (r1[2] - r2[2]) / 255, 0, r2[2] / 255, + 0, 0, 0, 0, opacity, + ].join(" ") + } + }, [from, to, opacity]) + + // Calculate threshold step arrays for the discrete component transfer + const stepsString = useMemo(() => { + const l = Math.max(2, levels) + const arr = [] + for (let i = 0; i < l; i++) { + arr.push((i / (l - 1)).toFixed(4)) + } + return arr.join(" ") + }, [levels]) + + // Calculate weights for the arithmetic composite step + // k2 scales the underlying gradient, k3 adds the Bayer matrix noise + const { k2, k3 } = useMemo(() => { + const l = Math.max(2, levels) + return { + k2: ((l - 1) / l).toFixed(4), + k3: (1 / l).toFixed(4), + } + }, [levels]) const bloomStyle = pixelBloomStyle(bloom) + // Avoid rendering filter elements before width/height are measured to prevent SSR layout shifts + if (dims.width === 0 || dims.height === 0) { + return
+ } + return (
- - {bloomStyle && ( - + + {/* Base uniform grayscale gradient */} + + + + + + {/* Core Procedural Filter Chain */} + + {/* 1. Import the Base64 Bayer matrix */} + {bayerDataUrl && ( + + )} + {/* 2. Tile the matrix across the entire container */} + + + {/* 3. Mathematically add the tiled matrix noise to the smooth grayscale gradient */} + + + {/* 4. Quantize the result into discrete steps (thresholding) */} + + + + + + + + {/* 5. Remap the dithered grayscale pattern into the final colors and alpha */} + + + + + {/* Render the actual element using the gradient and filter */} + - )} + + {/* Render a duplicated layer for bloom/glow effects if requested */} + {bloomStyle && ( + + )} +
) }