GitHub

shad-fx

Canvas effects for React, starting with Bayer dithered environmental effects. Rendered on a tiny canvas, using a single ImageData instance per frame, and upscaled with CSS image-rendering: pixelated to keep each frame fast and GPU accelerated.

npx shadcn@latest add @sekei/shad-fx
canvas
active
on
cell
2px
seed
1
surface
fire()
colors
height
0.5
rate
36
embers
8
import { DitherCanvas, fire } from "@/components/shad-fx";

const effect = fire();

<DitherCanvas effect={effect} />

Install

Take the whole library, or a renderer and the effects you want:

npx shadcn@latest add @sekei/shad-fx
npx shadcn@latest add @sekei/shad-fx-dither @sekei/shad-fx-fire

An effect on its own has nothing to draw on, so always take a renderer with it.

Nothing to configure. @sekei is in the shadcn registry directory, so the CLI resolves it and writes the registries entry into your components.json itself. To pin it yourself, by hand or in package.json — the CLI reads both:

{
  "registries": {
    "@sekei": "https://www.sekei.xyz/registry/{name}.json"
  }
}

Files land under components/shad-fx/ and hooks/, following your components.json aliases: renderers in dither/, effects in effects/. With the full library, import everything from @/components/shad-fx. With a renderer and a few effects, import from @/components/shad-fx/dither and @/components/shad-fx/effects/<name>.

ItemPulls in
shad-fxEvery renderer and effect, plus an index.ts barrel
shad-fx-ditherThe dither renderer, DitherCanvas. Engine, the reduced-motion hook, utils
shad-fx-fireEngine
shad-fx-ringsEngine
shad-fx-beamEngine
shad-fx-boltEngine
shad-fx-fluidEngine
shad-fx-rainEngine
shad-fx-snowEngine
shad-fx-engineNothing — frame loop, the effect contract, seeded RNG, colour helpers
use-prefers-reduced-motionNothing

If the namespace ever fails to resolve, the CLI can read the repo directly: prefix any item with sekeidesign/shad-fx/.

Usage

To have an agent set effects up for you, install the agent skills. One covers placement, the stable-reference rule, anchors and cost; the other writes new effects.

npx skills add sekeidesign/shad-fx

The canvas fills its nearest positioned ancestor, so give the parent relative.

import { DitherCanvas, fire } from "@/components/shad-fx";
import { useMemo } from "react";

export function Card() {
  const effect = useMemo(() => fire({ colors: ["#e5343a", "#f05100", "#fcbb00"] }), []);

  return (
    <div className="relative overflow-hidden rounded-lg">
      <DitherCanvas effect={effect} />
      <p className="relative">Burning</p>
    </div>
  );
}

Build the effect once. A new effect reference restarts the simulation — the canvas and its observer survive, but particles and heat fields reset. Use useMemo with the options in the dependency array, or module scope when the options are constant.

PropDefaultNotes
effect—The effect to run. Keep the reference stable.
activetrueEases in and out. Drive it from hover for a reveal.
cell2CSS px per dither cell. Lower is finer and costlier.
seed1Seeds the RNG, so a given seed replays identically.
maxCols / maxRows640 / 400Ceiling on the backing grid.
className—Merged onto the wrapper.

The element is aria-hidden and pointer-events-none: it is decoration, and never the only carrier of meaning.

Effects

To write an effect this set doesn't cover, have an agent do it with the agent skills. They cover the FxEffect contract, the Surface an effect paints into, reduced motion and parking.

npx skills add sekeidesign/shad-fx

Every effect is a factory returning an FxEffect, and every option is optional. The same effect runs on any renderer. RgbInput is a hex string or an [r, g, b] tuple. Where a count is given at full intensity, it scales down as the effect eases out.

fire()Rising flame front with embers.
OptionTypeDefaultDescription
colors[RgbInput, RgbInput, RgbInput]["#e5343a", "#f05100", "#fcbb00"]Cold to hot: the tips, the body, the base.
heightnumber | (() => number)0.5Fraction of the height the flames reach at full intensity. A getter is re-read every frame.
ratenumber36Simulation steps per second. Lower reads chunkier.
embersnumber8Embers aloft at once, at full intensity.
bolt()Lightning on a randomised interval.
OptionTypeDefaultDescription
colorRgbInput"#fcbb00"Colour of the strike and its afterglow.
interval[number, number][0.6, 1.4]Seconds between strikes at full intensity, as a [min, max] range.
targetAnchor[0.5, 0.43]What the strikes aim for. They land just short of it or on it.
ratenumber30Simulation steps per second. Lower reads chunkier.
rings()Sonar rings pulsing from an anchor.
OptionTypeDefaultDescription
colorRgbInput"#ac4bff"Colour of the rings.
originAnchor[0.5, 0.42]The point every ring expands from.
intervalnumber1.15Seconds between rings.
speednumber0.45Expansion speed as a fraction of the height per second.
widthnumber2.6Ring thickness in cells.
fluid()A sloshing level with rising bubbles.
OptionTypeDefaultDescription
colorRgbInput"#3080ff"Colour of the liquid and its bubbles.
levelnumber | (() => number)0.2Resting depth as a fraction of the height, at full intensity. A getter is re-read every frame.
sloshnumber0.09How far the surface tilts at either edge, as a fraction of the height.
temponumber0.15Slosh cycles per second.
bubblesnumber12Bubbles rising at once, at full intensity.
beam()A cone of light with drifting motes.
OptionTypeDefaultDescription
colorRgbInput"#3080ff"Colour of the light and its motes.
originAnchor[0.5, 0.5]Only the x is used: where the light enters at the top edge.
targetAnchorundefinedOnly the x is used: where the axis meets the bottom edge. Unset, the beam falls straight down from origin. Set, it leans toward this point.
spreadnumber0.5Half-width of the cone at the bottom edge, as a fraction of the width.
motesnumber16Dust motes drifting in the light.
rain()Slanted streaks, splashing on the floor.
OptionTypeDefaultDescription
colorRgbInput"#bedbff"Colour of the drops and splashes.
dropsnumber64Drops in flight at once, at full intensity.
speednumber1.4Fall speed of the nearest drops as a fraction of the height per second.
slantnumber | (() => number)0.25Cells drifted sideways per cell fallen. Negative blows left. A getter is re-read every frame.
lengthnumber6Streak length of the nearest drops, in cells.
snow()Drifting flakes that settle into a drift.
OptionTypeDefaultDescription
colorRgbInput"#d1d5dc"Colour of the flakes and the drift.
flakesnumber40Flakes aloft at once.
speednumber0.12Fall speed of the nearest flakes as a fraction of the height per second.
swaynumber0.6Sideways wander as a fraction of the fall speed.
settlenumber0.12Depth the snow settles to along the floor, as a fraction of the height. 0 for none.

origin and target take an Anchor: an [x, y] pair in 0–1 of the box, or a getter, which is re-read every frame — so an effect can follow something that moves without being rebuilt and losing what it has already simulated. Drag the dot in the playground above to see it.

Agent skills

Two skills, installed together, for an agent working in a project that uses shad-fx.

npx skills add sekeidesign/shad-fx
SkillUse it toCovers
use-shad-fxPut an effect on a card, hero, button or section, or fix one that restarts, flickers, hides the content or costs too much.Placement, keeping the effect reference stable, anchors, colours and cost.
create-shad-fxWrite an effect the shipped set doesn't cover, like smoke, sparks or static, or change how one simulates.The FxEffect contract, the Surface it paints into, reduced motion and parking.

Reduced motion

The canvas reads prefers-reduced-motion through useSyncExternalStore, so it is correct on the server and updates when the setting changes. Under reduce, each effect paints one settled frame and stops: fire is pre-warmed and still, rings sit at three fixed radii, rain and snow hang mid-fall, particles are dropped. Nothing animates and the frame loop parks.

Cost

The engine runs requestAnimationFrame only while something is changing, and stops once the eased intensity has settled and the effect reports idle(). An inactive effect costs nothing. Each frame is one putImageData over a grid capped at 640×400 cells, not a fillRect per cell.

Credit

The ordered-dither rendering here, meaning the low-resolution backing canvas scaled up pixelated, the Bayer threshold matrix, and filling every cell at one of two alpha tiers rather than leaving holes, derives from dither-kit under the MIT licence. The effects, the painter, the frame loop and the reduced-motion handling are not.