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
npx shadcn@latest add @sekei/shad-fx-ascii @sekei/shad-fx-fireAn 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.design/registry/{name}.json"
}
}Files land under components/shad-fx/ and hooks/, following your components.json aliases: renderers in dither/ and ascii/, 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 or @/components/shad-fx/ascii, and @/components/shad-fx/effects/<name>.
| Item | Pulls in |
|---|---|
shad-fx | Every renderer and effect, plus an index.ts barrel |
shad-fx-dither | The dither renderer, DitherCanvas. Engine, the reduced-motion hook, utils |
shad-fx-ascii | The ASCII renderer, AsciiCanvas. Engine, the reduced-motion hook, utils |
shad-fx-fire | Engine |
shad-fx-rings | Engine |
shad-fx-beam | Engine |
shad-fx-bolt | Engine |
shad-fx-fluid | Engine |
shad-fx-rain | Engine |
shad-fx-snow | Engine |
shad-fx-engine | Nothing — frame loop, the effect contract, useFx, seeded RNG, colour helpers |
use-prefers-reduced-motion | Nothing |
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, useFx, anchors and cost; the other writes new effects.
npx skills add sekeidesign/shad-fxThe canvas fills its nearest positioned ancestor, so give the parent relative.
"use client";
import { DitherCanvas, fire, useFx } from "@/components/shad-fx";
export function Card({ height }: { height: number }) {
const fx = useFx(fire, { colors: ["#e5343a", "#f05100", "#fcbb00"], height });
return (
<div className="relative overflow-hidden rounded-lg">
<DitherCanvas effect={fx} />
<p className="relative">Burning</p>
</div>
);
}useFx builds the effect once and keeps it in step with its options. Pass them like any props, inline arrays included: a change applies in place, without restarting the simulation, and a removed option goes back to its default. Passing a different effect, as in useFx(on ? fire : rain), starts that one fresh.
For a value that changes every frame, such as a pointer position, skip the re-render and call fx.set from the handler. Outside React, createFx(fire, options) returns the same handle.
Two renderers ship, and every effect runs on either: swap DitherCanvas for AsciiCanvas and nothing else changes. DitherCanvas draws each cell as a Bayer-thresholded pixel. AsciiCanvas draws coloured glyphs.
DitherCanvas
| Prop | Default | Notes |
|---|---|---|
effect | — | The effect to run. Keep the reference stable. |
active | true | Eases in and out. Drive it from hover for a reveal. |
cell | 2 | CSS px per dither cell. Lower is finer and costlier. |
seed | 1 | Seeds the RNG, so a given seed replays identically. |
maxCols / maxRows | 640 / 400 | Ceiling on the backing grid. |
className | — | Merged onto the wrapper. |
AsciiCanvas
| Prop | Default | Notes |
|---|---|---|
effect | — | The effect to run. Keep the reference stable. |
active | true | Eases in and out. Drive it from hover for a reveal. |
fontFamily | system mono | Any CSS font-family, by name. Load a web font with the page; the canvas redraws once it lands. |
fontSize | 10 | CSS px. A glyph is one advance wide and two tall. Larger is coarser and cheaper. |
ramp | .:-=+*#%@ | Glyphs from lightest to densest. Changes in place. |
seed | 1 | Seeds the RNG, so a given seed replays identically. |
maxCols / maxRows | 320 / 160 | Ceiling on the glyph grid. |
className | — | Merged onto the wrapper. |
The font defaults to the system monospace (ui-monospace, then SF Mono, Menlo, Consolas and so on, ending at monospace), so the component downloads nothing. To use your own, load it the way the page already loads fonts and pass its name, as in CSS: fontFamily="'Geist Mono', monospace". The canvas redraws once it lands. With next/font, pass geistMono.style.fontFamily; a CSS variable such as var(--font-geist-mono) works too. Any font works, monospaced or not, since each glyph is centred in its own cell.
Each glyph covers two square cells of the effect's grid, one per half, so rings stay round and rain keeps its slant even though glyphs are tall. The halves pick the glyph: evenly lit cells take the ramp, a lit top half takes ' or ", a lit bottom half . or _, and a sparkle becomes + or *. Colours are the effect's own.
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-fxEvery effect is a factory returning an FxEffect, passed to useFx, and every option is optional and can change at any time. 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.
| Option | Type | Default | Description |
|---|---|---|---|
colors | [RgbInput, RgbInput, RgbInput] | ["#e5343a", "#f05100", "#fcbb00"] | Cold to hot: the tips, the body, the base. |
height | number | 0.5 | Fraction of the height the flames reach at full intensity. |
rate | number | 36 | Simulation steps per second. Lower reads chunkier. |
embers | number | 8 | Embers aloft at once, at full intensity. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#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. |
target | Anchor | [0.5, 0.43] | What the strikes aim for. They land just short of it or on it. |
rate | number | 30 | Simulation steps per second. Lower reads chunkier. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#ac4bff" | Colour of the rings. |
origin | Anchor | [0.5, 0.42] | The point every ring expands from. |
interval | number | 1.15 | Seconds between rings. |
speed | number | 0.45 | Expansion speed as a fraction of the height per second. |
width | number | 2.6 | Ring thickness in cells. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#3080ff" | Colour of the liquid and its bubbles. |
level | number | 0.2 | Resting depth as a fraction of the height, at full intensity. |
slosh | number | 0.09 | How far the surface tilts at either edge, as a fraction of the height. |
tempo | number | 0.15 | Slosh cycles per second. |
bubbles | number | 12 | Bubbles rising at once, at full intensity. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#3080ff" | Colour of the light and its motes. |
origin | Anchor | [0.5, 0.5] | Only the x is used: where the light enters at the top edge. |
target | Anchor | undefined | Only 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. |
spread | number | 0.5 | Half-width of the cone at the bottom edge, as a fraction of the width. |
motes | number | 16 | Dust motes drifting in the light. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#bedbff" | Colour of the drops and splashes. |
drops | number | 64 | Drops in flight at once, at full intensity. |
speed | number | 1.4 | Fall speed of the nearest drops as a fraction of the height per second. |
slant | number | 0.25 | Cells drifted sideways per cell fallen. Negative blows left. |
length | number | 6 | Streak length of the nearest drops, in cells. |
| Option | Type | Default | Description |
|---|---|---|---|
color | RgbInput | "#d1d5dc" | Colour of the flakes and the drift. |
flakes | number | 40 | Flakes aloft at once. |
speed | number | 0.12 | Fall speed of the nearest flakes as a fraction of the height per second. |
sway | number | 0.6 | Sideways wander as a fraction of the fall speed. |
settle | number | 0.12 | Depth 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. To follow something that moves, call fx.set({ origin }) as it moves: the effect keeps 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| Skill | Use it to | Covers |
|---|---|---|
use-shad-fx | Put an effect on a card, hero, button or section, or fix one that restarts, flickers, hides the content or costs too much. | Placement, useFx and live options, anchors, colours and cost. |
create-shad-fx | Write 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, live set, reduced motion and parking. |
Reduced motion
Both canvases read 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. A canvas scrolled off screen pauses too, through an IntersectionObserver, and resumes where it left off as it comes back, so a page of them only pays for the ones in view.
A dither frame is one putImageData over a grid capped at 640×400 cells, upscaled with image-rendering: pixelated, not a fillRect per cell. An ASCII frame never calls fillText: glyphs are drawn once into an atlas, each lit glyph is one blit from it, and a single scaled draw colours them all. Blank glyphs cost nothing. A full-bleed hero with every glyph lit takes about 2ms a frame on a GPU-backed canvas.
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.