Skip to content
Sign in
EffectsEffect engineMenu

Effect enginePro

The shared runtime under every Pro effect: one WebGL2 context for the whole page, theme tokens as RGBA, a frame clock that stops offscreen, a still frame under reduced motion, error diffusion in a worker and an honest progress driver.

ProForty dithered tiles drawn by one shared WebGL2 context, with the runtime's own counters and the longest main thread task.

These are libraries and hooks, not components. Every effect installs the pieces it needs, so you only add them by hand to build your own effect.

Installation

$ pnpm dlx shadcn@latest add @chatcn-pro/chatcn-pro-effects

The other pieces install the same way: chatcn-pro-dither, use-chatcn-pro-theme-color, use-chatcn-pro-effect-motion, use-chatcn-pro-effect-image and effect-pause-control.

What is in it

PieceImportWhat it does
Effect runtime@/lib/chatcn-pro-effectscreateEffect(canvas, { fragment, fallback }): a fullscreen triangle runner with a uniform bag and textures, drawn by one shared WebGL2 context and copied into your canvas. Pixel cap, DPR snapping, a canvas 2D fallback, GLSL snippets, a shared frame clock and one IntersectionObserver
Theme colors@/hooks/use-chatcn-pro-theme-coloruseEffectColors({ ink, paper }, ref) resolves any token or CSS color to RGBA where ref sits and follows theme, preset and contrast changes
Motion@/hooks/use-chatcn-pro-effect-motionuseEffectMotion(ref) answers whether a loop may run; useFrameClock drives it; useProgressDriver smooths real progress
Dither toolkit@/lib/chatcn-pro-ditherBayer matrices, a 64 x 64 blue noise tile made in code, error diffusion in a worker, luminance sampling and a Geist Mono glyph atlas
Image plumbing@/hooks/use-chatcn-pro-effect-imageuseEffectImage(src) loads once per URL with a CORS check; useElementSize(ref)
Pause control@/components/ui/chatcn/pro/effect-pause-controlThe pause and play button for loops longer than 5 s (WCAG 2.2.2)

Usage

tsx
import { createEffect, GLSL } from "@/lib/chatcn-pro-effects"
import { useEffectColors } from "@/hooks/use-chatcn-pro-theme-color"
import { useEffectMotion, useFrameClock } from "@/hooks/use-chatcn-pro-effect-motion"

const effect = createEffect(canvas, {
  fragment: `${GLSL.bayer}
    uniform vec4 u_ink;
    uniform vec4 u_paper;
    void main() {
      float tone = v_uv.x;
      out_color = tone > chatcnBayer(chatcnPx(), 8) ? u_paper : u_ink;
    }`,
})

const { ink, paper } = useEffectColors({ ink: "var(--chatcn-fg)" }, ref)
const motion = useEffectMotion(ref)
useFrameClock((time) => effect.render(time), { running: motion.running, fps: 24 })

Examples

Progress driver

Providers report progress in uneven jumps and often sit at 99% while they upload. useProgressDriver(progress, { done }) eases between events, never goes backwards, creeps slowly through a silence without passing 95%, and runs to 100% in about 300 ms once done is set. Without any progress it creeps toward 90% over expectedMs.

ProReported progress jumps and stalls at 99%. The driven value eases, creeps without passing 95%, and finishes on done.

How it stays fast

  • One context. Browsers cap live WebGL contexts at about 16. Every effect draws into one hidden WebGL2 canvas and is copied into its own 2D canvas in the same task, so a feed of 40 tiles costs one context and each tile still scrolls and clips like any element.
  • Pixel cap. Backing stores use the device pixel ratio up to 2, then divide it by a whole number until the area fits maxPixels, so image-rendering: pixelated upscales evenly and dither cells never come out uneven.
  • Frame clock. Loops share one requestAnimationFrame, each with its own fps cap. A loop stops when its frame leaves the screen, when the tab is hidden and when the reader pauses it; time does not advance while stopped.
  • Off the main thread. Error diffusion is serial and cannot run in a shader, so it runs in a Web Worker made from a Blob URL, with a main thread fallback and a result cache. A theme change repaints the cached result without recomputing.
  • Images load once per URL with crossOrigin="anonymous", and a host without CORS headers falls back to the plain image instead of failing.

API

ExportNotes
createEffect(canvas, options)fragment, fallback, maxPixels (default 1.5 MP), maxDpr (2), renderer (auto, webgl, 2d). Returns resize, setUniforms, setTexture, render(time), onRestore, dispose, mode, size
GLSLluminance, bayer, blueNoise, halftone, hash, simplex, cover. Fragments get v_uv, u_resolution, u_time, u_dpr and chatcnPx()
fitBackingStore, cellPixels, coverLodSizing helpers behind the pixel cap
addFrameCallback, observeVisibility, onPageVisibilityThe shared loop and observers, for effects that do not use the hooks
loadEffectImage(src)Cached, decoded, CORS-checked. Rejects with EffectImageError
getEffectStats()Contexts created, live handles, frames drawn, last frame cost
useEffectColors, useThemeColor, resolveThemeColor, subscribeThemeTokens to RGBA in the scope of an element
useEffectMotion, useFrameClock, useProgressDriver, useReducedMotionLoop permission, clock and progress
ditherDiffuse, ditherOrdered, bayerMatrix, blueNoiseTexture, readLuminance, loadGlyphAtlas, asciiCellsThe dither toolkit

Pause control props

PropTypeNotes
paused, onPausedChangeboolean, (paused) => voidWire to useEffectMotion
corner"top-start" | "top-end" | "bottom-start" | "bottom-end"Logical, mirrors in RTL. Default bottom-end
reveal"hover" | "always"hover shows it when the group/effect frame is hovered or the button has focus; always shown on touch and while paused
labelstringNames the animation: "Pause noise field"

Accessibility

  • Any loop longer than 5 s shows the pause control: a real button with a label that says what it pauses, reachable by keyboard, with a 44 px target on touch.
  • The canvas is aria-hidden. The real content always exists: the <img> with your alt sits underneath and is what screen readers announce.
  • Under forced-colors: active the effect hides and the plain image shows with system colors.
  • Under prefers-contrast: more ink and paper snap to the text and canvas tokens, with no mid tones.

Motion

  • Under reduced motion useFrameClock never subscribes to the loop. It draws one frame at the still time you choose, the same picture on every visit.
  • Ambient loops run at 24 to 30 fps. Effects that only draw once (images, gradients) never touch the loop.